From e9dc04cb7daced5a40f128846038418d18ff245a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:48:57 +0000 Subject: [PATCH 1/7] fix(rest): require a send capability on the REST email door and bound its sender WIP: capability gate, sentBy bound to the caller, from bounded to the configured sender via the new optional IEmailService.getDefaultFrom. Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- .../plugins/plugin-email/src/email-service.ts | 9 ++ packages/rest/src/email-routes.ts | 122 +++++++++++++++++- packages/spec/src/contracts/email-service.ts | 18 +++ 3 files changed, 144 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-email/src/email-service.ts b/packages/plugins/plugin-email/src/email-service.ts index edffe5b4807..723c8535027 100644 --- a/packages/plugins/plugin-email/src/email-service.ts +++ b/packages/plugins/plugin-email/src/email-service.ts @@ -665,6 +665,15 @@ export class EmailService implements IEmailService { return !(this.options.transport instanceof LogTransport); } + /** + * `IEmailService.getDefaultFrom` — the sender {@link send} applies when the + * input names none, read live so it follows {@link setDefaultFrom} from the + * `mail` settings namespace. + */ + getDefaultFrom(): EmailAddress | undefined { + return this.options.defaultFrom; + } + /** Replace the default `from` address used when callers omit `input.from`. */ setDefaultFrom(from: EmailAddress | undefined): void { this.options.defaultFrom = from; diff --git a/packages/rest/src/email-routes.ts b/packages/rest/src/email-routes.ts index 30a8f5a0c88..466be075ee2 100644 --- a/packages/rest/src/email-routes.ts +++ b/packages/rest/src/email-routes.ts @@ -11,7 +11,8 @@ * `RestServer#registerEmailEndpoints` as it stood, re-indented one level and * otherwise unedited: the same route, registered at the same position, by the * same handler, answering the same bodies. It calls no class `static`, so no - * token of it changed. + * token of it changed. The send door's bounds (below, "Who may send, and as + * whom") were added to the moved body afterwards, as their own change. * * ## How the moved body reaches the class * @@ -41,11 +42,81 @@ * its docblock there explains), is exported from there and imported here: one * declaration, never a copy. The package entry does not re-export it, so the * published surface does not move. + * + * ## Who may send, and as whom (ADR-0124: the server is the authority) + * + * The door hands a caller's message to the deployment's own mail transport, + * so an authenticated session is not enough on its own. Three bounds sit + * between the auth floor and the send, every one of them answered before the + * service is called: + * + * 1. CAPABILITY — the caller must hold {@link EMAIL_SEND_CAPABILITY}. It is + * `manage_platform_settings`, the capability that already governs the + * mail surface: the mail settings page (sender address, transport) and + * the `sys_email` log are both gated on it. Reused rather than invented, + * so this door cannot admit a different set of callers than the surface + * that configures and audits the same transport. Checked BEFORE the + * provider lookup, so a refused caller does not learn from a `501` + * whether this deployment wired mail. The held set is read from the + * resolved context's `systemPermissions`, the platform's one capability + * resolution; no `isSystem` arm, since `isSystem` is never resolved + * from inbound HTTP. + * 2. ATTRIBUTION — `sentBy` is the authenticated caller, always. A body that + * names a different user is refused rather than silently rewritten; a + * body naming the caller (or none) is stamped with the caller. + * 3. SENDER — the envelope sender is the deployment's configured sender + * (`IEmailService.getDefaultFrom`). A `from` naming any other address is + * refused; one naming the configured address is accepted and replaced + * by the configured value, so no caller-chosen display name or spelling + * reaches the transport. A service that cannot report its sender, or a + * deployment with none configured, refuses a caller `from` (unknown is + * not permission). Custom `headers` may not set the sender either + * ({@link SENDER_HEADER_NAMES}). + * + * Every refusal is `403 PERMISSION_DENIED` in the nested ADR-0112 envelope — + * the standard-catalog code for the status, the spelling `enforceAuth` above + * it answers in. Server-internal senders (flows, notifications, auth mail) + * call `IEmailService.send` directly and never pass through this door. */ +import type { EmailAddress } from '@objectstack/spec/contracts'; import { logError } from './log.js'; import { seamOrUndefined, type RestServer } from './rest-server.js'; +/** + * The capability `POST {base}/email/send` requires. See the module header, + * bound 1, for why it is the mail surface's existing capability. + */ +export const EMAIL_SEND_CAPABILITY = 'manage_platform_settings'; + +/** + * Custom header names (compared case-insensitively) a caller of the send door + * may not set: each one states who the message is from, which bound 3 of the + * module header reserves to the configured sender. + */ +export const SENDER_HEADER_NAMES: ReadonlySet = new Set(['from', 'sender', 'return-path']); + +/** + * The bare address an {@link EmailAddress} names, lower-cased for comparison — + * or `undefined` when it names none. For the string form a trailing + * `
` wins over the display name before it. This only DECIDES whether + * a caller's `from` restates the configured sender: the value forwarded to + * the transport is always the configured one, never the caller's text. + */ +function addressOf(value: unknown): string | undefined { + let raw: unknown = value; + if (value && typeof value === 'object') raw = (value as { address?: unknown }).address; + if (typeof raw !== 'string') return undefined; + const angled = /<([^<>]*)>\s*$/.exec(raw); + const address = (angled ? angled[1] : raw).trim().toLowerCase(); + return address.length > 0 ? address : undefined; +} + +/** The nested ADR-0112 refusal every bound of this door answers with. */ +function refuse(res: any, message: string): void { + res.status(403).json({ error: { code: 'PERMISSION_DENIED', message } }); +} + /** * The `RestServer` members {@link registerEmailEndpoints} reads — every one of * them, and no other. Each is typed AS the class member (an indexed access on @@ -77,6 +148,14 @@ export function registerEmailEndpoints(this: EmailRouteHost, basePath: string): const context = await this.resolveExecCtx(environmentId, req); if (this.enforceAuth(req, res, context)) return; + // Bound 1 (module header): the capability, before the provider + // lookup so a refusal reveals nothing about the wiring. + const held: unknown = (context as any)?.systemPermissions; + if (!Array.isArray(held) || !held.includes(EMAIL_SEND_CAPABILITY)) { + refuse(res, `Sending email through this door requires the \`${EMAIL_SEND_CAPABILITY}\` capability.`); + return; + } + if (!this.emailServiceProvider) { res.status(501).json({ code: 'NOT_IMPLEMENTED', @@ -120,12 +199,45 @@ export function registerEmailEndpoints(this: EmailRouteHost, basePath: string): res.status(400).json({ code: 'INVALID_REQUEST', error: 'JSON body required' }); return; } - // Stamp sentBy from the authenticated context when caller didn't supply one. + // Bound 2 (module header): `sentBy` is the authenticated caller. + // `enforceAuth` above has already proved `userId` present. + const userId = String((context as any).userId); + if (body.sentBy !== undefined && body.sentBy !== userId) { + refuse(res, '`sentBy` is the authenticated caller; a send cannot be attributed to another user.'); + return; + } + + // Bound 3 (module header): the sender is the configured one. + const headers = body.headers; + if (headers && typeof headers === 'object') { + const named = Object.keys(headers).find((k) => SENDER_HEADER_NAMES.has(k.trim().toLowerCase())); + if (named !== undefined) { + refuse(res, `Custom header \`${named}\` cannot be set: the sender is the deployment's configured sender.`); + return; + } + } + let from: EmailAddress | undefined; + if (body.from !== undefined) { + const configured = typeof emailService.getDefaultFrom === 'function' + ? emailService.getDefaultFrom() + : undefined; + const configuredAddress = addressOf(configured); + if (!configuredAddress || addressOf(body.from) !== configuredAddress) { + refuse( + res, + configuredAddress + ? `\`from\` must be the deployment's configured sender (${configuredAddress}); omit it to send as that sender.` + : '`from` cannot be chosen by the caller: this deployment reports no configured sender. Configure one in Settings → Mail → From address and omit `from`.', + ); + return; + } + from = configured; + } + const input = { ...body, - ...(body.sentBy === undefined && (context as any)?.userId - ? { sentBy: (context as any).userId } - : {}), + sentBy: userId, + ...(from !== undefined ? { from } : {}), }; try { diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 5c5fe46f2ca..45a3bb71d3f 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -404,4 +404,22 @@ export interface IEmailService { * reopens the #15587 black hole (a fake success with no mail). */ isConfigured?(): boolean; + + /** + * The deployment's configured sender — the `from` {@link send} applies when + * the input names none (`defaultFrom` on the constructor channel, or + * Settings → Mail → From address on the settings channel). Read at call + * time, so it follows a sender changed in the `mail` settings namespace; + * `undefined` when no sender is configured. + * + * Read by the REST send door (`POST {base}/email/send`), which bounds an + * HTTP caller's `from` to this address. Server-internal senders (flows, + * notifications, auth mail) call {@link send} directly and are not bounded + * by it. + * + * Optional so existing implementations stay valid. A consumer MUST treat an + * absent member as UNKNOWN: a bound that cannot read the configured sender + * refuses a caller-chosen `from` rather than admitting it. + */ + getDefaultFrom?(): EmailAddress | undefined; } From 22fb812daa20e708f6a100a7874b20040da0a918 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:51:03 +0000 Subject: [PATCH 2/7] test(rest): pin the email send door's capability, attribution and sender bounds Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- .../plugin-email/src/email-service.test.ts | 11 + .../rest/src/email-send-door-bounds.test.ts | 242 ++++++++++++++++++ packages/rest/src/rest-server.ts | 5 + 3 files changed, 258 insertions(+) create mode 100644 packages/rest/src/email-send-door-bounds.test.ts diff --git a/packages/plugins/plugin-email/src/email-service.test.ts b/packages/plugins/plugin-email/src/email-service.test.ts index 1ef726efc3b..00e9184e193 100644 --- a/packages/plugins/plugin-email/src/email-service.test.ts +++ b/packages/plugins/plugin-email/src/email-service.test.ts @@ -88,6 +88,17 @@ describe('EmailService.isConfigured — does mail sent through it get delivered' }); }); +describe('EmailService.getDefaultFrom — the configured sender the REST send door bounds `from` to', () => { + it('answers the constructor sender, follows setDefaultFrom, and is undefined when none is configured', () => { + const svc = new EmailService({ transport: new LogTransport(), defaultFrom: 'no@reply.com' }); + expect(svc.getDefaultFrom()).toBe('no@reply.com'); + svc.setDefaultFrom({ name: 'Ops', address: 'ops@reply.com' }); + expect(svc.getDefaultFrom()).toEqual({ name: 'Ops', address: 'ops@reply.com' }); + svc.setDefaultFrom(undefined); + expect(svc.getDefaultFrom()).toBeUndefined(); + }); +}); + describe('EmailService', () => { function makePersistence() { const rows = new Map>(); diff --git a/packages/rest/src/email-send-door-bounds.test.ts b/packages/rest/src/email-send-door-bounds.test.ts new file mode 100644 index 00000000000..b6ce37210a1 --- /dev/null +++ b/packages/rest/src/email-send-door-bounds.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `POST /api/v1/email/send` — the send door's three bounds (`email-routes.ts` + * header, "Who may send, and as whom"): the capability, the `sentBy` + * attribution, and the configured sender. + * + * Driven through the real `HonoHttpServer` transport the CLI mounts, so every + * status, envelope and parsed body below is the wire's, not a mock's. The + * identity is stubbed at `resolveExecCtx` (the one resolution the door reads), + * and the mail service is a contract-typed double that records what reached + * `send` — so each refusal case also proves the service was NEVER called, and + * each admission case proves what it was called WITH. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +import type { EmailAddress, IEmailService, SendEmailInput } from '@objectstack/spec/contracts'; +import { RestServer } from './rest-server'; +import { EMAIL_SEND_CAPABILITY } from './email-routes'; + +const CONFIGURED: EmailAddress = { name: 'Acme Mail', address: 'no-reply@acme.example' }; +const ADMIN = { userId: 'usr_admin', systemPermissions: ['setup.access', EMAIL_SEND_CAPABILITY] }; +const MEMBER = { userId: 'usr_member', systemPermissions: ['setup.access'] }; +const MESSAGE = { to: 'someone@example.org', subject: 'Hello', text: 'body' }; + +interface Boot { + /** `undefined` ⇒ the deployment wires no email provider at all. */ + service?: Partial | null; + ctx: Record | undefined; +} + +function emailDouble(defaultFrom: EmailAddress | undefined = CONFIGURED, withAccessor = true) { + const send = vi.fn(async (_input: SendEmailInput) => ({ id: 'eml_1', status: 'sent' as const, messageId: 'm1' })); + const service: Partial = { + send, + ...(withAccessor ? { getDefaultFrom: () => defaultFrom } : {}), + }; + return { service, send }; +} + +function boot({ service, ctx }: Boot) { + const server = new HonoHttpServer(0); + const provider = vi.fn(async () => service ?? undefined); + const rest = new RestServer( + server as any, + {} as any, + { api: { requireAuth: false } } as any, + undefined, // kernelManager + undefined, // envRegistry + undefined, // defaultEnvironmentIdProvider + undefined, // authServiceProvider + undefined, // objectQLProvider + service === undefined ? undefined : provider, // emailServiceProvider + ); + (rest as any).resolveExecCtx = async () => ctx; + rest.registerRoutes(); + server.installNotFoundSeam(); + const post = async (body: unknown) => { + const res: Response = await server.getRawApp().fetch(new Request('http://local/api/v1/email/send', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + })); + return { status: res.status, body: (await res.json()) as any }; + }; + return { post, provider }; +} + +describe('POST /api/v1/email/send — the capability', () => { + it('a caller holding the capability sends: 200, and the service receives the message stamped with the caller', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post(MESSAGE); + + expect(out.status).toBe(200); + expect(out.body).toMatchObject({ status: 'sent', id: 'eml_1' }); + expect(send).toHaveBeenCalledTimes(1); + const input = send.mock.calls[0][0]; + expect(input).toMatchObject(MESSAGE); + expect(input.sentBy).toBe(ADMIN.userId); + // No caller `from` ⇒ none forwarded: the service applies its own sender. + expect('from' in input).toBe(false); + }); + + it('an authenticated caller without the capability is refused 403 PERMISSION_DENIED, and nothing is sent', async () => { + const { service, send } = emailDouble(); + const { post, provider } = boot({ service, ctx: MEMBER }); + const out = await post(MESSAGE); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(out.body.error.message).toContain(EMAIL_SEND_CAPABILITY); + expect(send).not.toHaveBeenCalled(); + // Refused before the provider lookup: the answer reveals nothing about the wiring. + expect(provider).not.toHaveBeenCalled(); + }); + + it('a context with no capability set at all is refused the same way', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: { userId: 'usr_bare' } }); + const out = await post(MESSAGE); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(send).not.toHaveBeenCalled(); + }); + + it('the capability is judged before the provider: no provider wired ⇒ 403 for the unentitled, 501 for the entitled', async () => { + const member = boot({ service: null, ctx: MEMBER }); + const refused = await member.post(MESSAGE); + expect(refused.status).toBe(403); + expect(refused.body.error.code).toBe('PERMISSION_DENIED'); + + const admin = boot({ service: null, ctx: ADMIN }); + const unwired = await admin.post(MESSAGE); + expect(unwired.status).toBe(501); + expect(unwired.body.code).toBe('NOT_IMPLEMENTED'); + }); + + it('anonymous is still refused 401 at the auth floor, ahead of the capability', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: undefined }); + const out = await post(MESSAGE); + + expect(out.status).toBe(401); + expect(out.body.error.code).toBe('UNAUTHENTICATED'); + expect(send).not.toHaveBeenCalled(); + }); + + it('a non-object body from an entitled caller is still 400 INVALID_REQUEST', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post('not an object'); + + expect(out.status).toBe(400); + expect(out.body.code).toBe('INVALID_REQUEST'); + expect(send).not.toHaveBeenCalled(); + }); +}); + +describe('POST /api/v1/email/send — `sentBy` is the authenticated caller', () => { + it('a body naming another user as `sentBy` is refused 403, and nothing is sent', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post({ ...MESSAGE, sentBy: 'usr_someone_else' }); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(out.body.error.message).toContain('sentBy'); + expect(send).not.toHaveBeenCalled(); + }); + + it('a body naming the caller itself as `sentBy` is admitted, stamped with the caller', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post({ ...MESSAGE, sentBy: ADMIN.userId }); + + expect(out.status).toBe(200); + expect(send.mock.calls[0][0].sentBy).toBe(ADMIN.userId); + }); +}); + +describe('POST /api/v1/email/send — the sender is the configured sender', () => { + it('a `from` naming another address is refused 403, and nothing is sent', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post({ ...MESSAGE, from: 'someone-else@other.example' }); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(out.body.error.message).toContain('from'); + expect(send).not.toHaveBeenCalled(); + }); + + it('a `from` restating the configured address is admitted, and the CONFIGURED value is what is forwarded', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + // Different case, a caller-chosen display name: the address matches, the + // caller's text does not travel. + const out = await post({ ...MESSAGE, from: 'Someone Else ' }); + + expect(out.status).toBe(200); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0].from).toEqual(CONFIGURED); + }); + + it('the object form is judged by its address the same way', async () => { + const admitted = emailDouble(); + const ok = await boot({ service: admitted.service, ctx: ADMIN }) + .post({ ...MESSAGE, from: { name: 'x', address: 'no-reply@acme.example' } }); + expect(ok.status).toBe(200); + expect(admitted.send.mock.calls[0][0].from).toEqual(CONFIGURED); + + const refused = emailDouble(); + const no = await boot({ service: refused.service, ctx: ADMIN }) + .post({ ...MESSAGE, from: { name: 'Acme Mail', address: 'other@acme.example' } }); + expect(no.status).toBe(403); + expect(refused.send).not.toHaveBeenCalled(); + }); + + it('with no configured sender, a caller `from` is refused — unknown is not permission', async () => { + const { service, send } = emailDouble(undefined); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post({ ...MESSAGE, from: 'no-reply@acme.example' }); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(send).not.toHaveBeenCalled(); + }); + + it('a service that cannot report its sender refuses a caller `from` — and still sends without one', async () => { + const blind = emailDouble(CONFIGURED, false); + const refused = await boot({ service: blind.service, ctx: ADMIN }) + .post({ ...MESSAGE, from: 'no-reply@acme.example' }); + expect(refused.status).toBe(403); + expect(blind.send).not.toHaveBeenCalled(); + + const admitted = await boot({ service: blind.service, ctx: ADMIN }).post(MESSAGE); + expect(admitted.status).toBe(200); + expect(blind.send).toHaveBeenCalledTimes(1); + }); + + it.each(['From', 'sender', 'Return-Path'])('a custom `%s` header is refused 403, and nothing is sent', async (name) => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post({ ...MESSAGE, headers: { [name]: 'someone-else@other.example' } }); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(send).not.toHaveBeenCalled(); + }); + + it('other custom headers pass through untouched', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post({ ...MESSAGE, headers: { 'X-Campaign': 'q4' } }); + + expect(out.status).toBe(200); + expect(send.mock.calls[0][0].headers).toEqual({ 'X-Campaign': 'q4' }); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index d70b11fea43..722bf586f8d 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5764,6 +5764,11 @@ export class RestServer { * 501 when no provider is wired so deployments without email * configured fail cleanly. * + * The caller must hold `manage_platform_settings`; `sentBy` is always the + * authenticated caller and `from` is bounded to the configured sender — + * each refused `403 PERMISSION_DENIED` otherwise (`email-routes.ts` + * header, "Who may send, and as whom"). + * * Request body: * { * to: "a@b.com" | ["a@b.com", { name, address }], From d1d57eed40fce12a32be985cc720aff9af53167b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:02:31 +0000 Subject: [PATCH 3/7] test(rest): correct the anonymous envelope read and the no-sender double Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- packages/rest/src/email-send-door-bounds.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rest/src/email-send-door-bounds.test.ts b/packages/rest/src/email-send-door-bounds.test.ts index b6ce37210a1..15b479e4120 100644 --- a/packages/rest/src/email-send-door-bounds.test.ts +++ b/packages/rest/src/email-send-door-bounds.test.ts @@ -30,11 +30,11 @@ interface Boot { ctx: Record | undefined; } -function emailDouble(defaultFrom: EmailAddress | undefined = CONFIGURED, withAccessor = true) { +function emailDouble(defaultFrom: EmailAddress | null = CONFIGURED, withAccessor = true) { const send = vi.fn(async (_input: SendEmailInput) => ({ id: 'eml_1', status: 'sent' as const, messageId: 'm1' })); const service: Partial = { send, - ...(withAccessor ? { getDefaultFrom: () => defaultFrom } : {}), + ...(withAccessor ? { getDefaultFrom: () => defaultFrom ?? undefined } : {}), }; return { service, send }; } @@ -124,7 +124,7 @@ describe('POST /api/v1/email/send — the capability', () => { const out = await post(MESSAGE); expect(out.status).toBe(401); - expect(out.body.error.code).toBe('UNAUTHENTICATED'); + expect(out.body.code).toBe('UNAUTHENTICATED'); expect(send).not.toHaveBeenCalled(); }); @@ -200,7 +200,7 @@ describe('POST /api/v1/email/send — the sender is the configured sender', () = }); it('with no configured sender, a caller `from` is refused — unknown is not permission', async () => { - const { service, send } = emailDouble(undefined); + const { service, send } = emailDouble(null); const { post } = boot({ service, ctx: ADMIN }); const out = await post({ ...MESSAGE, from: 'no-reply@acme.example' }); From 4c61757288758abf43383cde81f08063f299198f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:13:42 +0000 Subject: [PATCH 4/7] docs(rest): changeset, SDK and service docs, and the checklist clause for the email send door bounds Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- .changeset/harden-rest-email-send-door.md | 23 +++++++++++++++++++ .../kernel/runtime-services/email-service.mdx | 10 ++++++++ .../areas/integration-system.json | 22 +++++++++++------- packages/client/src/index.ts | 9 ++++++-- 4 files changed, 54 insertions(+), 10 deletions(-) create mode 100644 .changeset/harden-rest-email-send-door.md diff --git a/.changeset/harden-rest-email-send-door.md b/.changeset/harden-rest-email-send-door.md new file mode 100644 index 00000000000..e512c938b88 --- /dev/null +++ b/.changeset/harden-rest-email-send-door.md @@ -0,0 +1,23 @@ +--- +'@objectstack/rest': patch +'@objectstack/spec': patch +'@objectstack/plugin-email': patch +'@objectstack/client': patch +--- + +fix(rest): the REST email send door requires a send capability and bounds the sender address + +Clause-②: no +Metadata-protocol: none + +`POST /api/v1/email/send` (and its environment-scoped twin) now applies three bounds after the authentication floor and before anything is sent. Each refusal is `403 PERMISSION_DENIED` in the nested `{ error: { code, message } }` envelope, and the mail service is not called: + +- **Capability.** The caller must hold `manage_platform_settings`, the capability that already governs the mail settings page and the `sys_email` log. An authenticated caller without it is refused. The check runs before the provider lookup, so a refused caller gets `403` whether or not mail is wired. +- **Attribution.** `sentBy` is always the authenticated caller. A body that names another user is refused. A body that names the caller, or names no one, is stamped with the caller. +- **Sender.** The envelope sender is the deployment's configured sender (Settings → Mail → From address, or `defaultFrom`). A `from` naming any other address is refused. A `from` naming the configured address is accepted, and the configured value is sent in its place. Custom `From`, `Sender` and `Return-Path` headers are refused. + +Server-internal sends (flows, notifications, auth mail) call `IEmailService.send` directly and are unchanged. + +`IEmailService` gains an optional `getDefaultFrom()` that returns the configured sender, and plugin-email's `EmailService` implements it. A service that does not implement it still sends through the door, but a caller `from` is refused because the sender cannot be checked. + +Migration: an integration that sends through the REST door needs a credential holding `manage_platform_settings`. It should omit `from` (the configured sender is used) and omit `sentBy` (the caller is recorded). diff --git a/content/docs/kernel/runtime-services/email-service.mdx b/content/docs/kernel/runtime-services/email-service.mdx index c03244d4dc3..361485537e4 100644 --- a/content/docs/kernel/runtime-services/email-service.mdx +++ b/content/docs/kernel/runtime-services/email-service.mdx @@ -15,6 +15,16 @@ services.email.send(input: SendEmailInput): Promise services.email.sendTemplate(input: SendTemplateInput): Promise ``` +`services.email.getDefaultFrom?.()` returns the configured sender, the `from` that `send()` uses when the input names none. It is `undefined` when no sender is configured. + +## Over HTTP + +`POST /api/v1/email/send` (`client.email.send`) hands a message to `send()`. Plugins and flows call `send()` directly and are not affected by what follows. The HTTP door checks three things first, and refuses a failure with `403 PERMISSION_DENIED` before anything is sent: + +- The caller must hold the `manage_platform_settings` capability. +- `sentBy` is always the authenticated caller. A body that names another user is refused. +- The sender is the deployment's configured sender. A `from` naming another address is refused. A `from` naming the configured address is replaced by the configured value. Custom `From`, `Sender` and `Return-Path` headers are refused. Omit `from` to send as the configured sender. + ## Returns `SendEmailResult`: diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index d5e2c07caac..95f6d8e5eed 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -876,10 +876,10 @@ }, { "id": "integration-system.email-template-render", - "title": "Email templates resolve from the registry (no sys_email_template row since ADR-0131 D6/D10), resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, refuses anonymous, and 400s malformed input", + "title": "Email templates resolve from the registry (no sys_email_template row since ADR-0131 D6/D10), resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, requires manage_platform_settings, bounds sentBy and from, refuses anonymous, and 400s malformed input", "since": "v15", "status": "active", - "revision": 9, + "revision": 10, "priority": "P2", "surface": "mixed", "fixtures": { @@ -913,7 +913,7 @@ "edit the template wording as an admin in Studio (an environment overlay over the declared item), redeploy/reboot, and re-read the served item over GET /api/v1/meta/email_template/showcase_task_done_email — the admin's wording must still be served", "on the WRITABLE showcase copy (fixtures.requires[3]), edit the DECLARED source template and metadata-reload WITHOUT a reboot, then GET /api/v1/meta/email_template/ — the registry serves the edited item (email_template is allowRuntimeCreate:true; the plugin subscribes to metadata changes). On the stock read-only showcase this half is not drivable: record it partial, as QA run #23166 did", "build the two rejection probes (stray key `body`; name 'BadName') and capture the errors", - "drive the raw send door POST /api/v1/email/send three ways: (a) authed with a well-formed message { to, subject, html } (or `text`; SendEmailInput's body keys are `html`/`text` — `bodyHtml`/`bodyText` are the TEMPLATE authoring fields, not wire keys) → capture status + the dev-transport landing; (b) anonymous (no session) → capture status; (c) a non-object / malformed body → capture the envelope", + "drive the raw send door POST /api/v1/email/send four ways: (a) as an admin (holding manage_platform_settings) with a well-formed message { to, subject, html } (or `text`; SendEmailInput's body keys are `html`/`text` — `bodyHtml`/`bodyText` are the TEMPLATE authoring fields, not wire keys) → capture status + the dev-transport landing; (b) anonymous (no session) → capture status; (c) a non-object / malformed body → capture the envelope; (d) as a member WITHOUT manage_platform_settings (mint one through admin create-user), and as the admin with a `from` other than the configured sender and with a `sentBy` naming another user → capture each refusal and confirm no new sys_email row", "while you are at the raw door, send the wrong-vocabulary body { to, subject, bodyHtml } deliberately and capture the refusal — 400 VALIDATION_FAILED 'at least one of text or html is required'. This is the shape the checklist itself used to name (run #7690); keeping the probe pins the two vocabularies apart" ], "acceptance": [ @@ -960,10 +960,10 @@ "evidence": "the error texts" }, { - "clause": "the raw transactional send door (POST /api/v1/email/send → IEmailService.send, complementary to the sendTemplate path above) authenticates and validates: an AUTHED well-formed message — { to, subject } plus AT LEAST ONE of `html` / `text` — lands at the dev transport (200 with result.status 'sent'); an ANONYMOUS send is refused 401 UNAUTHENTICATED (the #3963 unconditional gate — the api.requireAuth opt-out is retired); a MALFORMED body is refused 400 with a ledgered envelope code (INVALID_REQUEST for a non-object body, VALIDATION_FAILED for a bad message shape) — never a 500 for caller-fixable input, and a runtime with no email provider answers 501 NOT_IMPLEMENTED rather than a fake success", + "clause": "the raw transactional send door (POST /api/v1/email/send → IEmailService.send, complementary to the sendTemplate path above) authenticates, authorizes and validates: an AUTHED well-formed message from a caller holding manage_platform_settings — { to, subject } plus AT LEAST ONE of `html` / `text` — lands at the dev transport (200 with result.status 'sent'), sent from the configured sender and attributed (sent_by) to the caller; an authenticated caller WITHOUT that capability is refused 403 PERMISSION_DENIED and nothing is sent; a `sentBy` naming another user, a `from` naming an address other than the configured sender, and a custom From / Sender / Return-Path header are each refused 403 PERMISSION_DENIED before any send; an ANONYMOUS send is refused 401 UNAUTHENTICATED (the #3963 unconditional gate — the api.requireAuth opt-out is retired); a MALFORMED body is refused 400 with a ledgered envelope code (INVALID_REQUEST for a non-object body, VALIDATION_FAILED for a bad message shape) — never a 500 for caller-fixable input, and a runtime with no email provider answers 501 NOT_IMPLEMENTED rather than a fake success", "oracle": "api", - "verify": "the three POST /api/v1/email/send responses: authed 200 + dev-transport capture, anonymous 401 UNAUTHENTICATED, malformed 400 with the named code (email-routes.ts registerEmailEndpoints: enforceAuth, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider). ⚠ WIRE KEYS (corrected, run #7690): the send input is SendEmailInput { to, subject, text?, html?, from?, cc?, bcc?, replyTo?, … } (packages/spec/src/contracts/email-service.ts) — NOT `bodyHtml`. `bodyHtml`/`bodyText` are the email-TEMPLATE authoring fields (this item's own variants list at the `body:` and alias lines says so); posting them to this door is refused 400 'VALIDATION_FAILED: at least one of text or html is required' (packages/plugins/plugin-email/src/email-service.ts). A 400 here is the run using the wrong vocabulary, not a product defect — re-send with `html` before filing anything", - "evidence": "the three responses + the dev-transport landing (plus the wrong-vocabulary refusal, if the probe was driven)" + "verify": "the POST /api/v1/email/send responses: admin 200 + dev-transport capture, anonymous 401 UNAUTHENTICATED, malformed 400 with the named code, member 403 PERMISSION_DENIED, foreign from / sentBy 403 PERMISSION_DENIED with no sys_email row (email-routes.ts registerEmailEndpoints: enforceAuth, the capability / sentBy / sender bounds → 403 PERMISSION_DENIED, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider). ⚠ WIRE KEYS (corrected, run #7690): the send input is SendEmailInput { to, subject, text?, html?, from?, cc?, bcc?, replyTo?, … } (packages/spec/src/contracts/email-service.ts) — NOT `bodyHtml`. `bodyHtml`/`bodyText` are the email-TEMPLATE authoring fields (this item's own variants list at the `body:` and alias lines says so); posting them to this door is refused 400 'VALIDATION_FAILED: at least one of text or html is required' (packages/plugins/plugin-email/src/email-service.ts). A 400 here is the run using the wrong vocabulary, not a product defect — re-send with `html` before filing anything", + "evidence": "the responses + the dev-transport landing (plus the wrong-vocabulary refusal, if the probe was driven)" } ], "negative": [ @@ -976,14 +976,14 @@ ], "automated": { "kind": "e2e", - "ref": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts — on its own fixture package (auth.password_reset), not the showcase template: acceptance[0] (the registry serves the authored item beside the built-in and sendTemplate renders it, the subject exact and the body by containment), and halves only of acceptance[3] (the exact zh-CN member is reachable; not the no-folding case or the TEMPLATE_NOT_FOUND end) and of acceptance[5] (a Studio save sends on the next mail with no restart; not redeploy survival, not the declared-source reload). + packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts ('after a cold boot the metadata door and the rendered mail both carry the admin's wording') — acceptance[5]'s redeploy-survival half on the showcase. + packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts ('control: a locale nobody saved keeps the package ladder') — acceptance[3]'s en-US rung for a locale with no row (ja-JP). acceptance[6] and [7] rest on no pin, and acceptance[1], [2] and [4] on one below their oracle's layer: packages/plugins/plugin-email/src/send-template.test.ts drives EmailService over a fake loader and a capture transport, asserting MISSING_VARIABLES (acceptance[1]), TEMPLATE_INACTIVE (acceptance[2]) and the fromOverride and derived text (acceptance[4]) at unit — whether the error names the variable, the registry-resolved inactive item, replyTo and the row stay hand-driven: drive them by hand" + "ref": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts — on its own fixture package (auth.password_reset), not the showcase template: acceptance[0] (the registry serves the authored item beside the built-in and sendTemplate renders it, the subject exact and the body by containment), and halves only of acceptance[3] (the exact zh-CN member is reachable; not the no-folding case or the TEMPLATE_NOT_FOUND end) and of acceptance[5] (a Studio save sends on the next mail with no restart; not redeploy survival, not the declared-source reload). + packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts ('after a cold boot the metadata door and the rendered mail both carry the admin's wording') — acceptance[5]'s redeploy-survival half on the showcase. + packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts ('control: a locale nobody saved keeps the package ladder') — acceptance[3]'s en-US rung for a locale with no row (ja-JP). acceptance[6] rests on no pin; acceptance[7]'s capability, sentBy and sender bounds are pinned below the e2e layer by packages/rest/src/email-send-door-bounds.test.ts (the real Hono transport over a contract-typed mail-service double — the dev-transport landing and the sys_email row stay hand-driven), and acceptance[1], [2] and [4] on one below their oracle's layer: packages/plugins/plugin-email/src/send-template.test.ts drives EmailService over a fake loader and a capture transport, asserting MISSING_VARIABLES (acceptance[1]), TEMPLATE_INACTIVE (acceptance[2]) and the fromOverride and derived text (acceptance[4]) at unit — whether the error names the variable, the registry-resolved inactive item, replyTo and the row stay hand-driven: drive them by hand" }, "source": [ "packages/spec/src/system/email-template.zod.ts#fromOverride (requireds, dotted-name regex, aliases #5013, variables/required, fromOverride/replyTo/active)", "packages/spec/liveness/email_template.json#requireVars (the whole surface live via #4509; per-prop evidence incl. requireVars, TEMPLATE_INACTIVE, seed-not-clobber, runtime re-materialization)", "packages/plugins/plugin-email/src/email-service.ts ((name, locale) + fallback through the registry TemplateLoader; the requireVars and TEMPLATE_INACTIVE gates) + email-template-door.ts (the closed sys_email_template organization door, ADR-0131 D6) — the row projector bootstrap-declared-email-templates.ts was retired with the table projection (#22875)", "examples/app-showcase/src/system/emails/index.ts#showcase_task_done_email (showcase_task_done_email fixture)", - "packages/rest/src/email-routes.ts#registerEmailEndpoints (registerEmailEndpoints — POST /api/v1/email/send: enforceAuth 401 UNAUTHENTICATED #3963, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider, 500 EMAIL_SEND_FAILED)", + "packages/rest/src/email-routes.ts#registerEmailEndpoints (registerEmailEndpoints — POST /api/v1/email/send: enforceAuth 401 UNAUTHENTICATED #3963, the manage_platform_settings / sentBy / configured-sender bounds → 403 PERMISSION_DENIED, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider, 500 EMAIL_SEND_FAILED)", "packages/rest/src/rest-route-ledger.ts (email family — POST /api/v1/email/send → client email.send)", "packages/spec/src/api/error-code-ledger.zod.ts#EMAIL_SEND_FAILED (EMAIL_SEND_FAILED under @objectstack/rest)", "packages/spec/src/contracts/email-service.ts#SendEmailInput (SendEmailInput — the WIRE shape of POST /api/v1/email/send: to, subject, text?, html?; 'at least one of text or html must be supplied') + packages/plugins/plugin-email/src/email-service.ts#normalizeMessage (normalizeMessage — where the VALIDATION_FAILED texts come from)" @@ -1042,6 +1042,12 @@ "date": "2026-10-27", "change": "automated.ref names send-template.test.ts under acceptance[1], [2] and [4]. Checklist-accuracy finding of the 18.2.0 wave 2 (QA run #26450, release-18.2.0-w2-tier1-b, at 756220c4; wave anchor #25807, slice A): the ref said those clauses rest on no pin, while the mapping pass scored them partial from that unit file. No clause changed", "ref": "#25807" + }, + { + "revision": 10, + "date": "2026-10-29", + "change": "acceptance[7] gains the send door's authorization bounds: the door now requires manage_platform_settings, records the caller as sent_by and bounds from to the configured sender (each refused 403 PERMISSION_DENIED before any send). Step (a) drives the door as an admin, a new step (d) drives the refusals, and automated.ref names the REST-layer pin", + "ref": "claude/harden-email-send-door" } ] }, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 8bd11d6b8b3..bcb507184e2 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2490,8 +2490,13 @@ export class ObjectStackClient { /** * Send a message. Returns the service outcome verbatim — branch on * `status` (`sent` / `queued` / `failed`); the route answers 200 for all - * three so failures carry their diagnostic body. `sentBy` defaults - * server-side to the authenticated user. + * three so failures carry their diagnostic body. + * + * The caller must hold `manage_platform_settings`. `sentBy` is always the + * authenticated user, and the sender is the deployment's configured + * sender. Omit `from` and `sentBy`: a `from` naming another address, a + * `sentBy` naming another user, or a custom `From` / `Sender` / + * `Return-Path` header is refused `403 PERMISSION_DENIED`. */ send: async (message: { to: string | Array; From 5a8a02333cb35c9c4aa77e0656e65b54b9f69263 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:24:33 +0000 Subject: [PATCH 5/7] test(rest): grant the send capability where existing email-door cases measure what lies past it Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- .../src/meta-state-route-engine-outage.test.ts | 17 +++++++++++++++-- packages/rest/src/rest.test.ts | 14 +++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/rest/src/meta-state-route-engine-outage.test.ts b/packages/rest/src/meta-state-route-engine-outage.test.ts index da50e9ec8e7..52b16a8e7be 100644 --- a/packages/rest/src/meta-state-route-engine-outage.test.ts +++ b/packages/rest/src/meta-state-route-engine-outage.test.ts @@ -144,6 +144,7 @@ import { } from '@objectstack/core'; // `.js` on purpose — NodeNext resolution requires the extension. import { RestServer } from './rest-server.js'; +import { EMAIL_SEND_CAPABILITY } from './email-routes.js'; const STATE_PATH = '/api/v1/meta/object/:name/state/:field'; const EMAIL_PATH = '/api/v1/email/send'; @@ -494,9 +495,21 @@ describe('[#15405] §4 this consumer agrees with the `computeExecCtx` consumer o // ⚠️ Second observation only: no 404/503 question, no shipped reachability. // --------------------------------------------------------------------------- -/** `POST /email/send` gates on `enforceAuth`; the kernel host clears it. */ +/** + * `POST /email/send` gates on `enforceAuth`; the kernel host clears it. The + * door also requires {@link EMAIL_SEND_CAPABILITY} ahead of the provider + * lookup (`email-routes.ts`, bound 1), and this section measures the provider + * seam, so the resolved caller is granted it — otherwise every case below + * would read the capability refusal, never the seam. + */ function driveEmail(emailServiceProvider: any): Promise { - return driveOn(boot({ ...kernelHost(), emailServiceProvider }), 'POST', EMAIL_PATH, { + const rest = boot({ ...kernelHost(), emailServiceProvider }); + const resolve = (rest as any).resolveExecCtx.bind(rest); + (rest as any).resolveExecCtx = async (...a: unknown[]) => { + const ctx = await resolve(...a); + return ctx ? { ...ctx, systemPermissions: [...(ctx.systemPermissions ?? []), EMAIL_SEND_CAPABILITY] } : ctx; + }; + return driveOn(rest, 'POST', EMAIL_PATH, { body: { to: 'a@example.com', subject: 's', text: 't' }, }); } diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 551bf228146..4385c3b9e32 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1406,6 +1406,9 @@ describe('RestServer', () => { // POST /email/send — IEmailService bridge (M11.B1 / M10.7) // ----------------------------------------------------------------------- describe('email send handler', () => { + // The door requires the send capability (email-routes.ts, bound 1); these + // cases measure what lies past it, so the caller holds it. + const EMAIL_SENDER = { userId: 'test-user', systemPermissions: ['manage_platform_settings'] }; function getEmailRoute(rest: any) { const routes = rest.getRoutes(); return routes.find( @@ -1415,7 +1418,7 @@ describe('RestServer', () => { it('returns 501 when no email service provider is wired', async () => { const rest = new RestServer(server as any, protocol as any, ANON_API as any); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + (rest as any).resolveExecCtx = async () => EMAIL_SENDER; rest.registerRoutes(); const route = getEmailRoute(rest); expect(route).toBeDefined(); @@ -1427,13 +1430,14 @@ describe('RestServer', () => { it('calls email service and returns the SendEmailResult on success', async () => { const send = vi.fn(async () => ({ id: 'em-1', status: 'sent', messageId: '' })); - const provider = async () => ({ send }); + // A `from` is admitted only as the configured sender (bound 3). + const provider = async () => ({ send, getDefaultFrom: () => 'no@reply.com' }); const rest = new RestServer( server as any, protocol as any, ANON_API as any, undefined, undefined, undefined, undefined, undefined, provider as any, ); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + (rest as any).resolveExecCtx = async () => EMAIL_SENDER; rest.registerRoutes(); const route = getEmailRoute(rest); const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; @@ -1441,7 +1445,7 @@ describe('RestServer', () => { body: { to: 'a@b.com', from: 'no@reply.com', subject: 'Hi', text: 'x' }, } as any, res as any); expect(send).toHaveBeenCalledWith(expect.objectContaining({ - to: 'a@b.com', from: 'no@reply.com', subject: 'Hi', + to: 'a@b.com', from: 'no@reply.com', subject: 'Hi', sentBy: 'test-user', })); expect(res.json).toHaveBeenCalledWith({ id: 'em-1', status: 'sent', messageId: '' }); }); @@ -1454,7 +1458,7 @@ describe('RestServer', () => { undefined, undefined, undefined, undefined, undefined, provider as any, ); - (rest as any).resolveExecCtx = async () => ({ userId: 'test-user' }); + (rest as any).resolveExecCtx = async () => EMAIL_SENDER; rest.registerRoutes(); const route = getEmailRoute(rest); const res = { json: vi.fn(), status: vi.fn().mockReturnThis() }; From 5ba07d1f54a40bf23ff8c93f0d0caee6d51a6248 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:05:15 +0000 Subject: [PATCH 6/7] fix(rest)!: the email send door accepts only a plain string-valued headers map and derives the organization from the caller The REST send door now accepts custom headers only as the contract's Record (a plain object, header field names, single-line string values) before its reserved-sender-name check, stamps organizationId from the caller's vetted active organization (refusing a body that names another), and refuses identityMail. The SMTP transport hands nodemailer custom headers as an explicit name/value list, one entry per key. The spec contract test pins getDefaultFrom like isConfigured, the module header states which sys_email columns the capability gates, and the changeset declares the narrowing. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk --- .changeset/harden-rest-email-send-door.md | 20 ++- .../kernel/runtime-services/email-service.mdx | 7 +- .../areas/integration-system.json | 14 +- packages/client/src/index.ts | 13 +- .../plugin-email/src/transports/smtp.test.ts | 13 +- .../plugin-email/src/transports/smtp.ts | 8 +- packages/rest/src/email-routes.ts | 113 +++++++++++++--- .../rest/src/email-send-door-bounds.test.ts | 121 +++++++++++++++++- packages/rest/src/rest-server.ts | 6 +- .../spec/src/contracts/email-service.test.ts | 40 ++++++ 10 files changed, 314 insertions(+), 41 deletions(-) diff --git a/.changeset/harden-rest-email-send-door.md b/.changeset/harden-rest-email-send-door.md index e512c938b88..c1a8df9b3a4 100644 --- a/.changeset/harden-rest-email-send-door.md +++ b/.changeset/harden-rest-email-send-door.md @@ -5,19 +5,27 @@ '@objectstack/client': patch --- -fix(rest): the REST email send door requires a send capability and bounds the sender address +fix(rest)!: the REST email send door requires a send capability and bounds the sender, the custom headers, the organization and identity mail -Clause-②: no +Clause-②: yes (narrowing) — the REST send door refuses callers and bodies it admitted (the breaking half), and the published `IEmailService` contract gains an optional `getDefaultFrom()` (a widening, the same move as `isConfigured()` on the same interface). No spec key, export or config field is removed or renamed. Metadata-protocol: none +Skills-docs: none — no file under `skills/**` teaches `POST /api/v1/email/send`, `client.email.send` or `IEmailService` (a `git grep` of `skills/` for each names nothing), so no skill line changes. The runtime-services page, `content/docs/kernel/runtime-services/email-service.mdx` ("Over HTTP"), states the four bounds in this change. -`POST /api/v1/email/send` (and its environment-scoped twin) now applies three bounds after the authentication floor and before anything is sent. Each refusal is `403 PERMISSION_DENIED` in the nested `{ error: { code, message } }` envelope, and the mail service is not called: + -- **Capability.** The caller must hold `manage_platform_settings`, the capability that already governs the mail settings page and the `sys_email` log. An authenticated caller without it is refused. The check runs before the provider lookup, so a refused caller gets `403` whether or not mail is wired. +**BREAKING** (an accept-set narrowing of `POST /api/v1/email/send`), shipped as `patch` because no metadata protocol moves. + +`POST /api/v1/email/send` (and its environment-scoped twin) now applies four bounds after the authentication floor and before anything is sent. Each refusal is `403 PERMISSION_DENIED` in the nested `{ error: { code, message } }` envelope, and the mail service is not called: + +- **Capability.** The caller must hold `manage_platform_settings`, the capability that already governs the mail settings namespace, the `sys_identity_email` log and the two body columns of `sys_email`. An authenticated caller without it is refused. The check runs before the provider lookup, so a refused caller gets `403` whether or not mail is wired. - **Attribution.** `sentBy` is always the authenticated caller. A body that names another user is refused. A body that names the caller, or names no one, is stamped with the caller. -- **Sender.** The envelope sender is the deployment's configured sender (Settings → Mail → From address, or `defaultFrom`). A `from` naming any other address is refused. A `from` naming the configured address is accepted, and the configured value is sent in its place. Custom `From`, `Sender` and `Return-Path` headers are refused. +- **Sender.** The envelope sender is the deployment's configured sender (the Mail settings' From address, or `defaultFrom`). A `from` naming any other address is refused. A `from` naming the configured address is accepted, and the configured value is sent in its place. `headers` is accepted only as the contract's `Record`: a plain object whose every key is a header field name and whose every value is a single-line string. Any other shape is refused, and so are custom `From`, `Sender` and `Return-Path` headers. +- **Organization.** The message is recorded under the caller's active organization: the door sets `organizationId` from the caller's execution context, and the service stamps it onto `sys_email.organization_id`. A body naming another organization is refused, and so is a body naming one when the caller has no active organization. `identityMail` is refused (any value but `false`): identity mail is sent by the server itself. `replyTo` is unchanged; it is the caller's Reply-To address and makes no claim about the sender. Server-internal sends (flows, notifications, auth mail) call `IEmailService.send` directly and are unchanged. `IEmailService` gains an optional `getDefaultFrom()` that returns the configured sender, and plugin-email's `EmailService` implements it. A service that does not implement it still sends through the door, but a caller `from` is refused because the sender cannot be checked. -Migration: an integration that sends through the REST door needs a credential holding `manage_platform_settings`. It should omit `from` (the configured sender is used) and omit `sentBy` (the caller is recorded). +plugin-email's SMTP transport now hands nodemailer custom headers as an explicit list of name/value entries, one per key of the headers map, so every key is read as exactly one header name. A well-formed headers map puts the same headers on the wire as before. + +What changes for you: an integration that sends through the REST door needs a credential holding `manage_platform_settings`, and should leave `from`, `sentBy`, `organizationId` and `identityMail` out of the body, since the door supplies each of them. Custom headers are sent as a plain object of string values. diff --git a/content/docs/kernel/runtime-services/email-service.mdx b/content/docs/kernel/runtime-services/email-service.mdx index 361485537e4..fab5a1c9225 100644 --- a/content/docs/kernel/runtime-services/email-service.mdx +++ b/content/docs/kernel/runtime-services/email-service.mdx @@ -19,11 +19,14 @@ services.email.sendTemplate(input: SendTemplateInput): Promise ## Over HTTP -`POST /api/v1/email/send` (`client.email.send`) hands a message to `send()`. Plugins and flows call `send()` directly and are not affected by what follows. The HTTP door checks three things first, and refuses a failure with `403 PERMISSION_DENIED` before anything is sent: +`POST /api/v1/email/send` (`client.email.send`) hands a message to `send()`. Plugins and flows call `send()` directly and are not affected by what follows. The HTTP door checks four things first, and refuses a failure with `403 PERMISSION_DENIED` before anything is sent: - The caller must hold the `manage_platform_settings` capability. - `sentBy` is always the authenticated caller. A body that names another user is refused. -- The sender is the deployment's configured sender. A `from` naming another address is refused. A `from` naming the configured address is replaced by the configured value. Custom `From`, `Sender` and `Return-Path` headers are refused. Omit `from` to send as the configured sender. +- The sender is the deployment's configured sender. A `from` naming another address is refused. A `from` naming the configured address is replaced by the configured value. Omit `from` to send as the configured sender. `headers`, when sent, must be a plain object that maps each header name to a single-line string value. Any other shape is refused, and so are custom `From`, `Sender` and `Return-Path` headers. +- The message is recorded under the caller's active organization, which the door sets as `organizationId`. A body that names another organization is refused, and so is one that names an organization when the caller has none. `identityMail` is refused: identity mail (verification, password reset, invitations) is sent by the server itself. Omit both. + +`replyTo` is not bounded. It is the caller's choice of Reply-To address and makes no claim about the sender. ## Returns diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index 95f6d8e5eed..be55fd8305d 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -876,7 +876,7 @@ }, { "id": "integration-system.email-template-render", - "title": "Email templates resolve from the registry (no sys_email_template row since ADR-0131 D6/D10), resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, requires manage_platform_settings, bounds sentBy and from, refuses anonymous, and 400s malformed input", + "title": "Email templates resolve from the registry (no sys_email_template row since ADR-0131 D6/D10), resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, requires manage_platform_settings, bounds sentBy, from, headers, organizationId and identityMail, refuses anonymous, and 400s malformed input", "since": "v15", "status": "active", "revision": 10, @@ -913,7 +913,7 @@ "edit the template wording as an admin in Studio (an environment overlay over the declared item), redeploy/reboot, and re-read the served item over GET /api/v1/meta/email_template/showcase_task_done_email — the admin's wording must still be served", "on the WRITABLE showcase copy (fixtures.requires[3]), edit the DECLARED source template and metadata-reload WITHOUT a reboot, then GET /api/v1/meta/email_template/ — the registry serves the edited item (email_template is allowRuntimeCreate:true; the plugin subscribes to metadata changes). On the stock read-only showcase this half is not drivable: record it partial, as QA run #23166 did", "build the two rejection probes (stray key `body`; name 'BadName') and capture the errors", - "drive the raw send door POST /api/v1/email/send four ways: (a) as an admin (holding manage_platform_settings) with a well-formed message { to, subject, html } (or `text`; SendEmailInput's body keys are `html`/`text` — `bodyHtml`/`bodyText` are the TEMPLATE authoring fields, not wire keys) → capture status + the dev-transport landing; (b) anonymous (no session) → capture status; (c) a non-object / malformed body → capture the envelope; (d) as a member WITHOUT manage_platform_settings (mint one through admin create-user), and as the admin with a `from` other than the configured sender and with a `sentBy` naming another user → capture each refusal and confirm no new sys_email row", + "drive the raw send door POST /api/v1/email/send four ways: (a) as an admin (holding manage_platform_settings) with a well-formed message { to, subject, html } (or `text`; SendEmailInput's body keys are `html`/`text` — `bodyHtml`/`bodyText` are the TEMPLATE authoring fields, not wire keys) → capture status + the dev-transport landing; (b) anonymous (no session) → capture status; (c) a non-object / malformed body → capture the envelope; (d) as a member WITHOUT manage_platform_settings (mint one through admin create-user), and as the admin with a `from` other than the configured sender, with a `sentBy` naming another user, with a `headers` value that is not a plain object of single-line strings (a list of entries, say), with an `organizationId` other than the admin's active organization, and with `identityMail: true` → capture each refusal and confirm no new sys_email or sys_identity_email row; then send once more as the admin with no `organizationId` and confirm the new sys_email row's organization_id is the admin's active organization", "while you are at the raw door, send the wrong-vocabulary body { to, subject, bodyHtml } deliberately and capture the refusal — 400 VALIDATION_FAILED 'at least one of text or html is required'. This is the shape the checklist itself used to name (run #7690); keeping the probe pins the two vocabularies apart" ], "acceptance": [ @@ -960,9 +960,9 @@ "evidence": "the error texts" }, { - "clause": "the raw transactional send door (POST /api/v1/email/send → IEmailService.send, complementary to the sendTemplate path above) authenticates, authorizes and validates: an AUTHED well-formed message from a caller holding manage_platform_settings — { to, subject } plus AT LEAST ONE of `html` / `text` — lands at the dev transport (200 with result.status 'sent'), sent from the configured sender and attributed (sent_by) to the caller; an authenticated caller WITHOUT that capability is refused 403 PERMISSION_DENIED and nothing is sent; a `sentBy` naming another user, a `from` naming an address other than the configured sender, and a custom From / Sender / Return-Path header are each refused 403 PERMISSION_DENIED before any send; an ANONYMOUS send is refused 401 UNAUTHENTICATED (the #3963 unconditional gate — the api.requireAuth opt-out is retired); a MALFORMED body is refused 400 with a ledgered envelope code (INVALID_REQUEST for a non-object body, VALIDATION_FAILED for a bad message shape) — never a 500 for caller-fixable input, and a runtime with no email provider answers 501 NOT_IMPLEMENTED rather than a fake success", + "clause": "the raw transactional send door (POST /api/v1/email/send → IEmailService.send, complementary to the sendTemplate path above) authenticates, authorizes and validates: an AUTHED well-formed message from a caller holding manage_platform_settings — { to, subject } plus AT LEAST ONE of `html` / `text` — lands at the dev transport (200 with result.status 'sent'), sent from the configured sender, attributed (sent_by) to the caller and recorded (organization_id) under the caller's active organization; an authenticated caller WITHOUT that capability is refused 403 PERMISSION_DENIED and nothing is sent; a `sentBy` naming another user, a `from` naming an address other than the configured sender, a `headers` value that is not a plain object mapping header names to single-line strings, a custom From / Sender / Return-Path header, an `organizationId` other than the caller's active organization, and an `identityMail` other than false are each refused 403 PERMISSION_DENIED before any send; an ANONYMOUS send is refused 401 UNAUTHENTICATED (the #3963 unconditional gate — the api.requireAuth opt-out is retired); a MALFORMED body is refused 400 with a ledgered envelope code (INVALID_REQUEST for a non-object body, VALIDATION_FAILED for a bad message shape) — never a 500 for caller-fixable input, and a runtime with no email provider answers 501 NOT_IMPLEMENTED rather than a fake success", "oracle": "api", - "verify": "the POST /api/v1/email/send responses: admin 200 + dev-transport capture, anonymous 401 UNAUTHENTICATED, malformed 400 with the named code, member 403 PERMISSION_DENIED, foreign from / sentBy 403 PERMISSION_DENIED with no sys_email row (email-routes.ts registerEmailEndpoints: enforceAuth, the capability / sentBy / sender bounds → 403 PERMISSION_DENIED, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider). ⚠ WIRE KEYS (corrected, run #7690): the send input is SendEmailInput { to, subject, text?, html?, from?, cc?, bcc?, replyTo?, … } (packages/spec/src/contracts/email-service.ts) — NOT `bodyHtml`. `bodyHtml`/`bodyText` are the email-TEMPLATE authoring fields (this item's own variants list at the `body:` and alias lines says so); posting them to this door is refused 400 'VALIDATION_FAILED: at least one of text or html is required' (packages/plugins/plugin-email/src/email-service.ts). A 400 here is the run using the wrong vocabulary, not a product defect — re-send with `html` before filing anything", + "verify": "the POST /api/v1/email/send responses: admin 200 + dev-transport capture, anonymous 401 UNAUTHENTICATED, malformed 400 with the named code, member 403 PERMISSION_DENIED, foreign from / sentBy / organizationId, off-shape headers and identityMail 403 PERMISSION_DENIED with no new mail-log row, and the admin's row stamped with the admin's active organization (email-routes.ts registerEmailEndpoints: enforceAuth, the capability / sentBy / sender / tenant bounds → 403 PERMISSION_DENIED, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider). ⚠ WIRE KEYS (corrected, run #7690): the send input is SendEmailInput { to, subject, text?, html?, from?, cc?, bcc?, replyTo?, … } (packages/spec/src/contracts/email-service.ts) — NOT `bodyHtml`. `bodyHtml`/`bodyText` are the email-TEMPLATE authoring fields (this item's own variants list at the `body:` and alias lines says so); posting them to this door is refused 400 'VALIDATION_FAILED: at least one of text or html is required' (packages/plugins/plugin-email/src/email-service.ts). A 400 here is the run using the wrong vocabulary, not a product defect — re-send with `html` before filing anything", "evidence": "the responses + the dev-transport landing (plus the wrong-vocabulary refusal, if the probe was driven)" } ], @@ -976,14 +976,14 @@ ], "automated": { "kind": "e2e", - "ref": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts — on its own fixture package (auth.password_reset), not the showcase template: acceptance[0] (the registry serves the authored item beside the built-in and sendTemplate renders it, the subject exact and the body by containment), and halves only of acceptance[3] (the exact zh-CN member is reachable; not the no-folding case or the TEMPLATE_NOT_FOUND end) and of acceptance[5] (a Studio save sends on the next mail with no restart; not redeploy survival, not the declared-source reload). + packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts ('after a cold boot the metadata door and the rendered mail both carry the admin's wording') — acceptance[5]'s redeploy-survival half on the showcase. + packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts ('control: a locale nobody saved keeps the package ladder') — acceptance[3]'s en-US rung for a locale with no row (ja-JP). acceptance[6] rests on no pin; acceptance[7]'s capability, sentBy and sender bounds are pinned below the e2e layer by packages/rest/src/email-send-door-bounds.test.ts (the real Hono transport over a contract-typed mail-service double — the dev-transport landing and the sys_email row stay hand-driven), and acceptance[1], [2] and [4] on one below their oracle's layer: packages/plugins/plugin-email/src/send-template.test.ts drives EmailService over a fake loader and a capture transport, asserting MISSING_VARIABLES (acceptance[1]), TEMPLATE_INACTIVE (acceptance[2]) and the fromOverride and derived text (acceptance[4]) at unit — whether the error names the variable, the registry-resolved inactive item, replyTo and the row stay hand-driven: drive them by hand" + "ref": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts — on its own fixture package (auth.password_reset), not the showcase template: acceptance[0] (the registry serves the authored item beside the built-in and sendTemplate renders it, the subject exact and the body by containment), and halves only of acceptance[3] (the exact zh-CN member is reachable; not the no-folding case or the TEMPLATE_NOT_FOUND end) and of acceptance[5] (a Studio save sends on the next mail with no restart; not redeploy survival, not the declared-source reload). + packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts ('after a cold boot the metadata door and the rendered mail both carry the admin's wording') — acceptance[5]'s redeploy-survival half on the showcase. + packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts ('control: a locale nobody saved keeps the package ladder') — acceptance[3]'s en-US rung for a locale with no row (ja-JP). acceptance[6] rests on no pin; acceptance[7]'s capability, sentBy, sender, headers-shape, organization and identity-mail bounds are pinned below the e2e layer by packages/rest/src/email-send-door-bounds.test.ts (the real Hono transport over a contract-typed mail-service double — the dev-transport landing and the sys_email row stay hand-driven), and acceptance[1], [2] and [4] on one below their oracle's layer: packages/plugins/plugin-email/src/send-template.test.ts drives EmailService over a fake loader and a capture transport, asserting MISSING_VARIABLES (acceptance[1]), TEMPLATE_INACTIVE (acceptance[2]) and the fromOverride and derived text (acceptance[4]) at unit — whether the error names the variable, the registry-resolved inactive item, replyTo and the row stay hand-driven: drive them by hand" }, "source": [ "packages/spec/src/system/email-template.zod.ts#fromOverride (requireds, dotted-name regex, aliases #5013, variables/required, fromOverride/replyTo/active)", "packages/spec/liveness/email_template.json#requireVars (the whole surface live via #4509; per-prop evidence incl. requireVars, TEMPLATE_INACTIVE, seed-not-clobber, runtime re-materialization)", "packages/plugins/plugin-email/src/email-service.ts ((name, locale) + fallback through the registry TemplateLoader; the requireVars and TEMPLATE_INACTIVE gates) + email-template-door.ts (the closed sys_email_template organization door, ADR-0131 D6) — the row projector bootstrap-declared-email-templates.ts was retired with the table projection (#22875)", "examples/app-showcase/src/system/emails/index.ts#showcase_task_done_email (showcase_task_done_email fixture)", - "packages/rest/src/email-routes.ts#registerEmailEndpoints (registerEmailEndpoints — POST /api/v1/email/send: enforceAuth 401 UNAUTHENTICATED #3963, the manage_platform_settings / sentBy / configured-sender bounds → 403 PERMISSION_DENIED, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider, 500 EMAIL_SEND_FAILED)", + "packages/rest/src/email-routes.ts#registerEmailEndpoints (registerEmailEndpoints — POST /api/v1/email/send: enforceAuth 401 UNAUTHENTICATED #3963, the manage_platform_settings / sentBy / configured-sender and headers-shape / caller-organization and identity-mail bounds → 403 PERMISSION_DENIED, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider, 500 EMAIL_SEND_FAILED)", "packages/rest/src/rest-route-ledger.ts (email family — POST /api/v1/email/send → client email.send)", "packages/spec/src/api/error-code-ledger.zod.ts#EMAIL_SEND_FAILED (EMAIL_SEND_FAILED under @objectstack/rest)", "packages/spec/src/contracts/email-service.ts#SendEmailInput (SendEmailInput — the WIRE shape of POST /api/v1/email/send: to, subject, text?, html?; 'at least one of text or html must be supplied') + packages/plugins/plugin-email/src/email-service.ts#normalizeMessage (normalizeMessage — where the VALIDATION_FAILED texts come from)" @@ -1046,7 +1046,7 @@ { "revision": 10, "date": "2026-10-29", - "change": "acceptance[7] gains the send door's authorization bounds: the door now requires manage_platform_settings, records the caller as sent_by and bounds from to the configured sender (each refused 403 PERMISSION_DENIED before any send). Step (a) drives the door as an admin, a new step (d) drives the refusals, and automated.ref names the REST-layer pin", + "change": "acceptance[7] gains the send door's authorization bounds: the door now requires manage_platform_settings, records the caller as sent_by and the caller's active organization as organization_id, bounds from to the configured sender, accepts headers only as a plain object of single-line strings, and refuses identityMail (each refused 403 PERMISSION_DENIED before any send). Step (a) drives the door as an admin, a new step (d) drives the refusals and the organization stamp, and automated.ref names the REST-layer pin", "ref": "claude/harden-email-send-door" } ] diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index bcb507184e2..2a2c5a26bf8 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2493,10 +2493,15 @@ export class ObjectStackClient { * three so failures carry their diagnostic body. * * The caller must hold `manage_platform_settings`. `sentBy` is always the - * authenticated user, and the sender is the deployment's configured - * sender. Omit `from` and `sentBy`: a `from` naming another address, a - * `sentBy` naming another user, or a custom `From` / `Sender` / - * `Return-Path` header is refused `403 PERMISSION_DENIED`. + * authenticated user, the sender is the deployment's configured sender, + * and the message is recorded under the caller's active organization. + * Omit `from`, `sentBy`, `organizationId` and `identityMail`: a `from` + * naming another address, a `sentBy` naming another user, an + * `organizationId` naming another organization, any `identityMail` other + * than `false`, and a custom `From` / `Sender` / `Return-Path` header are + * each refused `403 PERMISSION_DENIED`. `headers`, when sent, is a plain + * object mapping header names to single-line string values; any other + * shape is refused the same way. */ send: async (message: { to: string | Array; diff --git a/packages/plugins/plugin-email/src/transports/smtp.test.ts b/packages/plugins/plugin-email/src/transports/smtp.test.ts index da5314910bc..faac40962c6 100644 --- a/packages/plugins/plugin-email/src/transports/smtp.test.ts +++ b/packages/plugins/plugin-email/src/transports/smtp.test.ts @@ -140,11 +140,22 @@ describe('SmtpTransport — send', () => { subject: 'S', text: 'T', html: '

H

', - headers: { 'X-Trace': '42' }, + headers: [{ key: 'X-Trace', value: '42' }], attachments: [{ filename: 'a.txt', content: 'body', contentType: 'text/plain', cid: 'cid1' }], }); }); + it('hands nodemailer the headers map as an explicit list, one name/value entry per key, in order', async () => { + await new SmtpTransport({ host: 'smtp.x' }).send({ + ...MSG, + headers: { 'X-Trace': '42', 'X-Campaign': 'q4' }, + }); + expect(nm.sendMail.mock.calls[0][0].headers).toEqual([ + { key: 'X-Trace', value: '42' }, + { key: 'X-Campaign', value: 'q4' }, + ]); + }); + it('surfaces the SMTP server error verbatim (authentication failure is LOUD)', async () => { nm.sendMail.mockRejectedValueOnce( new Error('Invalid login: 535 Error: authentication failed'), diff --git a/packages/plugins/plugin-email/src/transports/smtp.ts b/packages/plugins/plugin-email/src/transports/smtp.ts index 46c13267611..ad530551a05 100644 --- a/packages/plugins/plugin-email/src/transports/smtp.ts +++ b/packages/plugins/plugin-email/src/transports/smtp.ts @@ -223,7 +223,13 @@ export class SmtpTransport implements IEmailTransport { if (message.cc?.length) mail.cc = message.cc; if (message.bcc?.length) mail.bcc = message.bcc; if (message.replyTo) mail.replyTo = message.replyTo; - if (message.headers && Object.keys(message.headers).length > 0) mail.headers = message.headers; + // Custom headers go to nodemailer as an explicit list of name/value + // entries, one per key of the contract's `Record`, so + // every key is read as exactly one header name: the list is the one + // `headers` form nodemailer reads without interpreting the container. + if (message.headers && Object.keys(message.headers).length > 0) { + mail.headers = Object.entries(message.headers).map(([key, value]) => ({ key, value })); + } if (message.attachments?.length) { mail.attachments = message.attachments.map((a) => ({ filename: a.filename, diff --git a/packages/rest/src/email-routes.ts b/packages/rest/src/email-routes.ts index 466be075ee2..66764fa1181 100644 --- a/packages/rest/src/email-routes.ts +++ b/packages/rest/src/email-routes.ts @@ -46,18 +46,20 @@ * ## Who may send, and as whom (ADR-0124: the server is the authority) * * The door hands a caller's message to the deployment's own mail transport, - * so an authenticated session is not enough on its own. Three bounds sit + * so an authenticated session is not enough on its own. Four bounds sit * between the auth floor and the send, every one of them answered before the * service is called: * * 1. CAPABILITY — the caller must hold {@link EMAIL_SEND_CAPABILITY}. It is * `manage_platform_settings`, the capability that already governs the - * mail surface: the mail settings page (sender address, transport) and - * the `sys_email` log are both gated on it. Reused rather than invented, - * so this door cannot admit a different set of callers than the surface - * that configures and audits the same transport. Checked BEFORE the - * provider lookup, so a refused caller does not learn from a `501` - * whether this deployment wired mail. The held set is read from the + * mail surface: the `mail` settings namespace (sender address, transport) + * reads and writes on it, `sys_identity_email` requires it for the whole + * row, and `sys_email` requires it for its two body columns (`body_text`, + * `body_html`; the rest of a `sys_email` row is not gated on it). Reused + * rather than invented, so this door cannot admit a different set of + * callers than the surface that configures the same transport. Checked + * BEFORE the provider lookup, so a refused caller does not learn from a + * `501` whether this deployment wired mail. The held set is read from the * resolved context's `systemPermissions`, the platform's one capability * resolution; no `isSystem` arm, since `isSystem` is never resolved * from inbound HTTP. @@ -71,12 +73,32 @@ * reaches the transport. A service that cannot report its sender, or a * deployment with none configured, refuses a caller `from` (unknown is * not permission). Custom `headers` may not set the sender either - * ({@link SENDER_HEADER_NAMES}). + * ({@link SENDER_HEADER_NAMES}), and the door accepts them in one shape + * only: the contract's `Record`, a plain object whose + * every key is a header field name and whose every value is a single-line + * string ({@link headersRefusal}). Any other shape is refused, so the + * reserved-name check reads each header exactly as a transport will. + * 4. TENANT — the message's mail-log row belongs to the caller's active + * organization: the `tenantId` the identity resolver vetted onto the + * execution context (`meta-item-read-gate.ts`, "The caller's + * organization", says why that and not the session's stored claim). The + * door stamps it as `organizationId`, which the service stamps verbatim + * onto `sys_email.organization_id`. A body naming any other organization, + * or naming one when the caller has no active organization, is refused. + * `identityMail` is refused too: identity mail (verification, password + * reset, invitations) is sent by the server itself, and its log, + * `sys_identity_email`, is not a destination an HTTP caller chooses. A + * body restating the door's own answer (`identityMail: false`) is + * admitted, and the forwarded message never carries the caller's value. * * Every refusal is `403 PERMISSION_DENIED` in the nested ADR-0112 envelope — * the standard-catalog code for the status, the spelling `enforceAuth` above * it answers in. Server-internal senders (flows, notifications, auth mail) * call `IEmailService.send` directly and never pass through this door. + * + * `replyTo` is NOT bounded. The contract (`SendEmailInput.replyTo`) declares + * it a caller-supplied address for the Reply-To header and makes no sender + * claim of it, so no configured value exists to bound it to. */ import type { EmailAddress } from '@objectstack/spec/contracts'; @@ -96,6 +118,46 @@ export const EMAIL_SEND_CAPABILITY = 'manage_platform_settings'; */ export const SENDER_HEADER_NAMES: ReadonlySet = new Set(['from', 'sender', 'return-path']); +/** + * A header field name: one or more printable US-ASCII characters other than + * the colon (RFC 5322 §2.2). Whitespace, control characters and non-ASCII + * characters are not part of any field name. + */ +const HEADER_FIELD_NAME = /^[\x21-\x39\x3b-\x7e]+$/; + +/** A plain object — an object literal or a JSON-parsed object, never an array or a class instance. */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Why a caller's `headers` cannot be forwarded, or `undefined` when it can + * (module header, bound 3). Absent is fine. Otherwise it must be the + * contract's `Record` exactly: a plain object, each key a + * header field name, each value a string with no line break, and no key one + * of {@link SENDER_HEADER_NAMES}. + */ +function headersRefusal(headers: unknown): string | undefined { + if (headers === undefined) return undefined; + if (!isPlainObject(headers)) { + return 'Custom `headers` must be an object mapping each header name to a string value.'; + } + for (const [name, value] of Object.entries(headers)) { + if (!HEADER_FIELD_NAME.test(name)) { + return 'Custom `headers` must be keyed by header field names: printable ASCII with no space, control character or colon.'; + } + if (typeof value !== 'string' || /[\r\n]/.test(value)) { + return `Custom header \`${name}\` must have a single-line string value.`; + } + if (SENDER_HEADER_NAMES.has(name.toLowerCase())) { + return `Custom header \`${name}\` cannot be set: the sender is the deployment's configured sender.`; + } + } + return undefined; +} + /** * The bare address an {@link EmailAddress} names, lower-cased for comparison — * or `undefined` when it names none. For the string form a trailing @@ -208,13 +270,10 @@ export function registerEmailEndpoints(this: EmailRouteHost, basePath: string): } // Bound 3 (module header): the sender is the configured one. - const headers = body.headers; - if (headers && typeof headers === 'object') { - const named = Object.keys(headers).find((k) => SENDER_HEADER_NAMES.has(k.trim().toLowerCase())); - if (named !== undefined) { - refuse(res, `Custom header \`${named}\` cannot be set: the sender is the deployment's configured sender.`); - return; - } + const headersRefused = headersRefusal(body.headers); + if (headersRefused !== undefined) { + refuse(res, headersRefused); + return; } let from: EmailAddress | undefined; if (body.from !== undefined) { @@ -234,9 +293,31 @@ export function registerEmailEndpoints(this: EmailRouteHost, basePath: string): from = configured; } + // Bound 4 (module header): the row belongs to the caller's + // active organization, and identity mail is the server's. + const tenantId: unknown = (context as any).tenantId; + const organizationId = typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : undefined; + if (body.organizationId !== undefined && body.organizationId !== organizationId) { + refuse( + res, + organizationId + ? '`organizationId` is the caller\'s active organization; a send cannot be recorded under another organization.' + : '`organizationId` cannot be set: the caller has no active organization. Omit it.', + ); + return; + } + if (body.identityMail !== undefined && body.identityMail !== false) { + refuse(res, '`identityMail` cannot be set through this door: identity mail is sent by the server itself. Omit it.'); + return; + } + + const message = { ...body }; + delete message.organizationId; + delete message.identityMail; const input = { - ...body, + ...message, sentBy: userId, + ...(organizationId !== undefined ? { organizationId } : {}), ...(from !== undefined ? { from } : {}), }; diff --git a/packages/rest/src/email-send-door-bounds.test.ts b/packages/rest/src/email-send-door-bounds.test.ts index 15b479e4120..688cb57e431 100644 --- a/packages/rest/src/email-send-door-bounds.test.ts +++ b/packages/rest/src/email-send-door-bounds.test.ts @@ -1,9 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `POST /api/v1/email/send` — the send door's three bounds (`email-routes.ts` + * `POST /api/v1/email/send` — the send door's four bounds (`email-routes.ts` * header, "Who may send, and as whom"): the capability, the `sentBy` - * attribution, and the configured sender. + * attribution, the configured sender (with the one `headers` shape the door + * accepts), and the caller's organization (with identity mail kept + * server-side). * * Driven through the real `HonoHttpServer` transport the CLI mounts, so every * status, envelope and parsed body below is the wire's, not a mock's. The @@ -22,6 +24,8 @@ import { EMAIL_SEND_CAPABILITY } from './email-routes'; const CONFIGURED: EmailAddress = { name: 'Acme Mail', address: 'no-reply@acme.example' }; const ADMIN = { userId: 'usr_admin', systemPermissions: ['setup.access', EMAIL_SEND_CAPABILITY] }; const MEMBER = { userId: 'usr_member', systemPermissions: ['setup.access'] }; +/** The admin acting inside an active organization (the vetted `tenantId`). */ +const ORG_ADMIN = { ...ADMIN, tenantId: 'org_acme' }; const MESSAGE = { to: 'someone@example.org', subject: 'Hello', text: 'body' }; interface Boot { @@ -240,3 +244,116 @@ describe('POST /api/v1/email/send — the sender is the configured sender', () = expect(send.mock.calls[0][0].headers).toEqual({ 'X-Campaign': 'q4' }); }); }); + +describe('POST /api/v1/email/send — `headers` is accepted as a plain string-valued map only', () => { + // Each shape below carries no reserved header name at all: the door refuses + // it for its SHAPE, because only the contract's `Record` is + // read by the reserved-name check exactly as a transport reads it. + it.each([ + ['a list of entries', [{ key: 'X-Campaign', value: 'q4' }]], + ['a map with an object value', { 'X-Campaign': { value: 'q4' } }], + ['a map with a number value', { 'X-Priority': 1 }], + ['a map with a multi-line value', { 'X-Campaign': 'q4\r\nX-Other: 1' }], + ['a map keyed by a name with a space', { 'X Campaign': 'q4' }], + ['a map keyed by a name with a colon', { 'X-Campaign:': 'q4' }], + ['a map keyed by a non-ASCII name', { 'X-Campaña': 'q4' }], + ['null', null], + ['a string', 'X-Campaign: q4'], + ])('%s is refused 403 PERMISSION_DENIED, and nothing is sent', async (_label, headers) => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post({ ...MESSAGE, headers }); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(out.body.error.message).toContain('headers'); + expect(send).not.toHaveBeenCalled(); + }); + + it('an empty map and a well-formed map are admitted, forwarded as sent', async () => { + const empty = emailDouble(); + const none = await boot({ service: empty.service, ctx: ADMIN }).post({ ...MESSAGE, headers: {} }); + expect(none.status).toBe(200); + expect(empty.send.mock.calls[0][0].headers).toEqual({}); + + const listed = emailDouble(); + const headers = { 'X-Campaign': 'q4', 'List-Unsubscribe': '' }; + const ok = await boot({ service: listed.service, ctx: ADMIN }).post({ ...MESSAGE, headers }); + expect(ok.status).toBe(200); + expect(listed.send.mock.calls[0][0].headers).toEqual(headers); + }); +}); + +describe('POST /api/v1/email/send — the row belongs to the caller\'s organization', () => { + it('a caller in an active organization sends with that organization stamped', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ORG_ADMIN }); + const out = await post(MESSAGE); + + expect(out.status).toBe(200); + expect(send.mock.calls[0][0].organizationId).toBe(ORG_ADMIN.tenantId); + }); + + it('a body restating the caller\'s organization is admitted, stamped with it', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ORG_ADMIN }); + const out = await post({ ...MESSAGE, organizationId: ORG_ADMIN.tenantId }); + + expect(out.status).toBe(200); + expect(send.mock.calls[0][0].organizationId).toBe(ORG_ADMIN.tenantId); + }); + + it('a body naming another organization is refused 403, and nothing is sent', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ORG_ADMIN }); + const out = await post({ ...MESSAGE, organizationId: 'org_other' }); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(out.body.error.message).toContain('organizationId'); + expect(send).not.toHaveBeenCalled(); + }); + + it('a caller with no active organization may not name one: refused 403, and nothing is sent', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post({ ...MESSAGE, organizationId: 'org_acme' }); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(send).not.toHaveBeenCalled(); + }); + + it('a caller with no active organization sends with none forwarded', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post(MESSAGE); + + expect(out.status).toBe(200); + expect('organizationId' in send.mock.calls[0][0]).toBe(false); + }); +}); + +describe('POST /api/v1/email/send — identity mail is not sent through this door', () => { + it.each([true, 'true', 1])('`identityMail: %s` is refused 403, and nothing is sent', async (identityMail) => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ADMIN }); + const out = await post({ ...MESSAGE, identityMail }); + + expect(out.status).toBe(403); + expect(out.body.error.code).toBe('PERMISSION_DENIED'); + expect(out.body.error.message).toContain('identityMail'); + expect(send).not.toHaveBeenCalled(); + }); + + it('`identityMail: false` restates the door\'s answer: admitted, and the forwarded message carries no `identityMail`', async () => { + const { service, send } = emailDouble(); + const { post } = boot({ service, ctx: ORG_ADMIN }); + const out = await post({ ...MESSAGE, identityMail: false }); + + expect(out.status).toBe(200); + const input = send.mock.calls[0][0]; + expect('identityMail' in input).toBe(false); + expect(input.organizationId).toBe(ORG_ADMIN.tenantId); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 722bf586f8d..3ff84c5c5ba 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -5765,8 +5765,10 @@ export class RestServer { * configured fail cleanly. * * The caller must hold `manage_platform_settings`; `sentBy` is always the - * authenticated caller and `from` is bounded to the configured sender — - * each refused `403 PERMISSION_DENIED` otherwise (`email-routes.ts` + * authenticated caller, `from` is bounded to the configured sender, + * `headers` must be a plain string-valued map, `organizationId` is the + * caller's active organization and `identityMail` is not the caller's to + * set — each refused `403 PERMISSION_DENIED` otherwise (`email-routes.ts` * header, "Who may send, and as whom"). * * Request body: diff --git a/packages/spec/src/contracts/email-service.test.ts b/packages/spec/src/contracts/email-service.test.ts index 195bd954500..22520ad7c43 100644 --- a/packages/spec/src/contracts/email-service.test.ts +++ b/packages/spec/src/contracts/email-service.test.ts @@ -109,3 +109,43 @@ describe('Email Service Contract — isConfigured (optional deliverability membe expect(typeof bad).toBe('function'); }); }); + +/** + * `IEmailService.getDefaultFrom` — the OPTIONAL configured-sender member the + * REST send door bounds a caller's `from` to, pinned the way `isConfigured` + * is above: + * - an implementation without the member stays assignable (the widening is + * additive; absent reads as "unknown", and the door refuses a caller + * `from` on unknown); + * - when present it is a zero-argument function answering an + * `EmailAddress` (either form) or `undefined` when no sender is configured. + */ +describe('Email Service Contract — getDefaultFrom (optional configured-sender member)', () => { + const base = { + send: async () => ({ id: 'e1', status: 'sent' as const }), + sendTemplate: async () => ({ id: 'e1', status: 'sent' as const }), + renderTemplate: async () => ({ subject: 's', html: 'h', text: 't' }), + }; + + it('keeps an implementation without getDefaultFrom assignable (absent = unknown)', () => { + const legacy: IEmailService = { ...base }; + expect(legacy.getDefaultFrom).toBeUndefined(); + }); + + it('accepts either address form or undefined, and refuses a non-address answer at compile time', () => { + const bare: IEmailService = { ...base, getDefaultFrom: () => 'no-reply@acme.example' }; + const named: IEmailService = { ...base, getDefaultFrom: () => ({ name: 'Acme', address: 'no-reply@acme.example' }) }; + const none: IEmailService = { ...base, getDefaultFrom: () => undefined }; + expect(bare.getDefaultFrom?.()).toBe('no-reply@acme.example'); + expect(named.getDefaultFrom?.()).toEqual({ name: 'Acme', address: 'no-reply@acme.example' }); + expect(none.getDefaultFrom?.()).toBeUndefined(); + + type Member = NonNullable; + // @ts-expect-error getDefaultFrom answers an EmailAddress, not a number + const wrongType: Member = () => 42; + // @ts-expect-error the object form names its `address` + const noAddress: Member = () => ({ name: 'Acme' }); + expect(typeof wrongType).toBe('function'); + expect(typeof noAddress).toBe('function'); + }); +}); From 1b5c8eef84dfeb09fbd6be44435f73f5ae85ab04 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:17:12 +0000 Subject: [PATCH 7/7] fix(rest): name the `headers` field in the send door's value refusal Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk --- packages/rest/src/email-routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rest/src/email-routes.ts b/packages/rest/src/email-routes.ts index 66764fa1181..0cd9f8785d0 100644 --- a/packages/rest/src/email-routes.ts +++ b/packages/rest/src/email-routes.ts @@ -149,7 +149,7 @@ function headersRefusal(headers: unknown): string | undefined { return 'Custom `headers` must be keyed by header field names: printable ASCII with no space, control character or colon.'; } if (typeof value !== 'string' || /[\r\n]/.test(value)) { - return `Custom header \`${name}\` must have a single-line string value.`; + return `Custom \`headers\` value for \`${name}\` must be a single-line string.`; } if (SENDER_HEADER_NAMES.has(name.toLowerCase())) { return `Custom header \`${name}\` cannot be set: the sender is the deployment's configured sender.`;