Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/qa/platform-checklist/areas/integration-system.json
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,7 @@
{
"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",
"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 (rest-server.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",
"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)"
}
],
Expand All @@ -877,7 +877,7 @@
"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/rest-server.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, 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)"
Expand Down
3 changes: 2 additions & 1 deletion packages/qa/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1629,7 +1629,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', (
// [#23050 slice 5] Length 5 → 6 (`analytics-routes.ts`), still 0 over it.
// [#23050 slice 6] Length 6 → 7 (`security-routes.ts`), still 0 over it.
// [#23050 slice 7] Length 7 → 8 (`discovery-routes.ts`), still 0 over it.
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(8);
// [#23050 slice 8] Length 8 → 9 (`email-routes.ts`), still 0 over it.
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(9);
expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER);
const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8'));
for (const probe of [MECHANICS, ROUTES]) {
Expand Down
25 changes: 19 additions & 6 deletions packages/qa/dogfood/test/authz-probe-blind-spot.census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,11 +520,11 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND every ' +
'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' +
'`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' +
'`analytics-routes.ts`, `security-routes.ts` and `discovery-routes.ts`, the modules the bodies of ' +
'`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' +
'`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts` and `email-routes.ts`, the modules ' +
'the bodies of `registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' +
'`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' +
'`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints` and ' +
'`registerOpenApiEndpoints` moved to; ' +
'`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' +
'`registerOpenApiEndpoints` and `registerEmailEndpoints` moved to; ' +
'reachable = those inside registerMetadataEndpoints',
// [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and
// the numbers it produces did NOT move: 80 / 19 / 61, exactly as before.
Expand Down Expand Up @@ -733,25 +733,38 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
// the OpenAPI body reads `enforceAuth` (its host literal's forwarder, two;
// its host interface's member, two) — the discovery document is served to
// anyone, so its literal forwards no `enforceAuth`. No call site was added.
// [#23050 slice 8] 70 / 19 / 51 UNCHANGED, the same measurement again:
// `registerEmailEndpoints`' body (one registration site, `POST
// {base}/email/send`, outside `registerMetadataEndpoints`, so in the blind
// spot) moved to `email-routes.ts`, which joins the list (length 8 -> 9)
// with its own control. `this.routeManager.register(` stays 63 over the
// nine files (22 here + 9 + 5 + 7 + 8 + 1 + 6 + 4 + 1),
// `private register*Endpoints(` stays 16 (the registrar is still declared
// here), and `enforceAuth` 96 -> 100 is RE-ANCHORED, not relaxed: the one
// call site moved with its route and is counted in both readings; the four
// new occurrences are this slice's seam, the same two spellings once (the
// host literal's forwarder, two; the host interface's member, two). No call
// site was added.
controls: {
'private register*Endpoints(': 16,
'this.routeManager.register(': 63,
'registrar list length': 8,
'registrar list length': 9,
'data-action-routes.ts: this.routeManager.register(': 9,
'batch-routes.ts: this.routeManager.register(': 5,
'approvals-routes.ts: this.routeManager.register(': 7,
'sharing-routes.ts: this.routeManager.register(': 8,
'analytics-routes.ts: this.routeManager.register(': 1,
'security-routes.ts: this.routeManager.register(': 6,
'discovery-routes.ts: this.routeManager.register(': 4,
'email-routes.ts: this.routeManager.register(': 1,
// Both halves of the new rule carry their own control, so neither can go
// silently to zero: a helper deleted and its routes inlined back would
// still read population 80, and only these two controls would notice the
// shape moved and force this provenance to be re-read.
'registerPerItemRoute(': 8,
'const registerPerItemRoute =': 1,
'forwarder slice: this.routeManager.register(': 1,
enforceAuth: 96,
enforceAuth: 100,
},
note:
'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' +
Expand Down
164 changes: 164 additions & 0 deletions packages/rest/src/email-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#23050 slice 8] The email route of `RestServer` —
* `POST {basePath}/email/send`, the transactional send door onto the
* `IEmailService` provider — moved out of `rest-server.ts` whole. The
* registrar runs once per mounted base, unconditionally, so an
* environment-scoped deployment mounts the door twice.
*
* A MOVE, not a redesign. The function below is the body of
* `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.
*
* ## How the moved body reaches the class
*
* The shape slice 1 set (`data-action-routes.ts`' header carries the full
* reasoning), with slice 2's refinement. The class keeps the private method —
* the published `dist/*.d.ts` lists its name, docblock and position — and the
* body is one host literal ({@link EmailRouteHost}) plus
* `.call(host, basePath)`. A method is handed over as a closure that calls it
* on the server at call time, so a test double or spy set on the server is
* still the one the body reaches.
*
* Each field was checked on its own, not inherited from an earlier slice:
*
* - `routeManager` goes BY VALUE. The body reads it only for its one
* `register` call, which runs while registration runs, and the class's one
* `routeManager` reassignment wraps `registerMetadataEndpointsInner`, never
* this registrar.
* - `emailServiceProvider` goes as a GETTER. Both of the body's reads of it
* (the wiring guard and the provider call) are inside the handler, so they
* happen at REQUEST time, and the field is not `readonly`, so a value copied
* at registration could part from it. The getter reads the field the handler
* read, when it read it.
*
* Module-level helpers stay where they were. `logError` is imported from the
* module `rest-server.ts` imports it from. The one helper declared in
* `rest-server.ts` itself, `seamOrUndefined` (the provider-seam normalisation
* 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.
*/

import { logError } from './log.js';
import { seamOrUndefined, type RestServer } from './rest-server.js';

/**
* The `RestServer` members {@link registerEmailEndpoints} reads — every one of
* them, and no other. Each is typed AS the class member (an indexed access on
* the class), so a change to a member's signature on the class is a change
* here too, and naming a member the class does not have is a compile error.
* See the module header for why `emailServiceProvider` is a getter.
*/
export interface EmailRouteHost {
readonly routeManager: RestServer['routeManager'];
readonly emailServiceProvider: RestServer['emailServiceProvider'];
resolveExecCtx: RestServer['resolveExecCtx'];
enforceAuth: RestServer['enforceAuth'];
}

/**
* Register the transactional email send endpoint —
* `RestServer#registerEmailEndpoints`' body, called by that method with the
* server as `this`. The method's own docblock, which the published declaration
* carries, names the route and its request body.
*/
export function registerEmailEndpoints(this: EmailRouteHost, basePath: string): void {
const isScoped = basePath.includes('/environments/:environmentId');
this.routeManager.register({
method: 'POST',
path: `${basePath}/email/send`,
handler: async (req: any, res: any) => {
try {
const environmentId = isScoped ? req.params?.environmentId : undefined;
const context = await this.resolveExecCtx(environmentId, req);
if (this.enforceAuth(req, res, context)) return;

if (!this.emailServiceProvider) {
res.status(501).json({
code: 'NOT_IMPLEMENTED',
message: 'Email service is not configured on this deployment',
});
return;
}
// [#15405] `seamOrUndefined`, not the retired
// `.catch(() => undefined)`. That handler attaches to the
// promise the call RETURNS, so it can only ever see a
// REJECTION: a host wiring a non-`async` provider — which
// the seam's declared type cannot prevent, and
// `RestServer`'s constructor is the public wiring point —
// throws while the expression is still being evaluated, so
// there is no promise to attach to and the handler is never
// reached (commit add6a1b1c).
//
// ⚠️ NOT reachable from the SHIPPED wiring: the provider
// `rest-api-plugin.ts` hands over is declared `async`.
// Repaired because it is the same retired spelling at an
// embedder-reachable seam, ⛔ not on a claim of live impact.
//
// The ANSWER is deliberately unchanged — absorb to
// `undefined` and take the 501 below. ⛔ Unlike the engine
// seam, this one must NOT go loud: the
// `if (!this.emailServiceProvider)` guard above has already
// answered the wiring question, and "not configured" and
// "configured and unusable" both mean this deployment cannot
// send mail — one 501, no fact lost by folding them.
const emailService = await seamOrUndefined(() => this.emailServiceProvider!(environmentId));
if (!emailService || typeof emailService.send !== 'function') {
res.status(501).json({
code: 'NOT_IMPLEMENTED',
message: 'Email service is not configured on this deployment',
});
return;
}

const body = req.body ?? {};
if (!body || typeof body !== 'object') {
res.status(400).json({ code: 'INVALID_REQUEST', error: 'JSON body required' });
return;
}
// Stamp sentBy from the authenticated context when caller didn't supply one.
const input = {
...body,
...(body.sentBy === undefined && (context as any)?.userId
? { sentBy: (context as any).userId }
: {}),
};

try {
const result = await emailService.send(input);
if (result?.status === 'sent') {
res.status(200).json(result);
} else {
// failed / queued — still surface to client with 200 so clients can branch on status.
res.status(200).json(result);
}
} catch (err: any) {
// Validation errors from normalizeMessage are surfaced as 400.
const message = String(err?.message ?? err ?? 'send failed');
if (message.startsWith('VALIDATION_FAILED')) {
res.status(400).json({
code: 'VALIDATION_FAILED',
error: message.replace(/^VALIDATION_FAILED:\s*/, ''),
});
return;
}
throw err;
}
} catch (error: any) {
logError('[REST] Email send unhandled error:', error);
res.status(500).json({
code: 'EMAIL_SEND_FAILED',
error: String(error?.message ?? error ?? 'send failed').slice(0, 500),
});
}
},
metadata: {
summary: 'Send a transactional email via the configured EmailService',
tags: ['email'],
},
});
}
Loading
Loading