Skip to content

feat(messaging): IEmailService gains a render-only renderTemplate seam, consumed by the inbox channel (#9225) - #9248

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-9225-email-render-only-seam
Aug 17, 2026
Merged

os-steve merged 2 commits into
mainfrom
claude/issue-9225-email-render-only-seam

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #9225

Implements the maintainer-ruled seam (2026-08-17, comment on the card, accepted verbatim 「同意」): IEmailService gains a render-only method, implemented once in plugin-email beside the existing locale ladder + {{var}} renderer (ADR-0053 format filters included), consumed by the inbox channel the way the email channel consumes sendTemplate. One resolver, two channels; zero send-path changes.

What changed

  • packages/spec/src/contracts/email-service.ts — new RenderTemplateInput (template, data?, locale?, timezone?) and RenderTemplateResult (subject, html, text), and a required renderTemplate(input) method on IEmailService, documented with the same locale ladder and the same error vocabulary as sendTemplate (TEMPLATE_NOT_FOUND / TEMPLATE_INACTIVE / MISSING_VARIABLES), minus everything delivery-related.
  • packages/plugins/plugin-email/src/email-service.ts — the resolver + renderer that sendTemplate already used is extracted into a private resolveAndRenderTemplate (ladder, active check, variables_json validation, row-locale-authority render opts — moved verbatim, zero behaviour change); renderTemplate returns its output, sendTemplate delivers it. The send path is byte-for-byte the same code it was; the existing sendTemplate suite passes unchanged, which is the zero-send-path-change evidence.
  • packages/services/service-messaging/src/inbox-channel.ts — a delivery whose payload carries a notify template reference (the Flow notify nodes cannot be localized: title/message are raw strings with no template or locale channel, so a four-locale app sends English-only notifications #9205 path, read exactly as the email channel reads it) renders through the email service's render-only face: subject becomes the row's title, text becomes body_md. Locale: payload.locale, else the deployment default (same getDefaultTemplateLocale source as the email channel, Flow notify nodes cannot be localized: title/message are raw strings with no template or locale channel, so a four-locale app sends English-only notifications #9205/Auth emails are always en-US: no send names a locale and sys_user has no locale column, so localized template rows can never be selected #8195). No email service, or one without renderTemplate ⇒ the delivery fails LOUDLY (TEMPLATE_UNSUPPORTED); renderer error codes land on the delivery row; classifyError grades all four codes permanent (mirrors the email channel's Flow notify nodes cannot be localized: title/message are raw strings with no template or locale channel, so a four-locale app sends English-only notifications #9205 grading). Non-template deliveries are untouched.
  • packages/services/service-messaging/src/email-channel.ts — EmailSenderSurface (the structural view of the runtime-resolved email service) gains an optional renderTemplate mirror.
  • packages/services/service-messaging/src/messaging-service-plugin.ts — the lazy getEmail / getDefaultTemplateLocale resolvers are hoisted above inbox registration and shared by both channels (probed at delivery time, so init order and live service replacement don't matter — unchanged semantics).
  • packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts — consumer fixture triage: the one object literal in the tree typed IEmailService gains the new required method (an honest refusal stub; nothing in those tests renders without sending). Repo-wide sweep direction: downstream consumers of @objectstack/spec — searched every package for implements IEmailService and : IEmailService annotations; this fake was the only non-plugin-email implementer.
  • Tests: packages/plugins/plugin-email/src/render-template.test.ts (new — ladder, filter + timezone, html→text derivation, error vocabulary, and a zero-send assertion: no transport call, no sys_email insert) and template-path cases in inbox-channel.test.ts.
  • .changeset/email-render-only-seam.md — minor for spec / plugin-email / service-messaging.
  • packages/spec/api-surface/ + export-origins/ regenerated (exactly the two new interface exports).

Return-shape note (dispatch assumption 1, measured)

The ruling spells the result { subject, body }; the card body spells it { subject, html/text }. Measured: sys_email_template rows carry subject / body_html / body_text?, and the render already produces subject + html + text (text derived from html when the row declares none), while the inbox row needs title + body_md. A single body would force the contract to pick one face and lose the other, so the implemented shape is { subject, html, text } — the card body's spelling made concrete. The inbox consumes subject/text; an email-shaped consumer uses html/text. Assumption 2 held as stated: the inbox write site consumed the rendered content with no new contract face (channel options within the same package only).

Verification (all at 567619ff7, clean tree)

  • pnpm --filter @objectstack/spec test — 407 files, 10846 passed
  • pnpm --filter @objectstack/plugin-email test — 26 files, 417 passed
  • pnpm --filter @objectstack/service-messaging test — 22 files, 242 passed
  • pnpm --filter @objectstack/plugin-auth test — 55 files, 1261 passed
  • pnpm typecheck (spec, plugin-email, service-messaging, plugin-auth, rest) — clean; plugin-email/service-messaging typecheck resolving the newly-exported contract names is itself proof the rebuilt spec .d.ts was read (a stale dist could only fail these imports, not pass them)
  • Reverse verification (fix committed first): inbox-channel.ts restored to origin/main ⇒ 5 of the 6 new template-path tests red (the sixth, "never consults the renderer for a non-template delivery", stays green vacuously on the old channel — reported as observed); restored, suite green
  • Derived gates re-taken from the actual diff (node scripts/pm/dispatch-gates.mjs): changeset-gate-self-tests, cross-package-test-inputs, merge-driver, objectui-changeset, spec-parsed-alias, test-source-alias, type-source-resolution, query-options-erasure, nul-bytes, spec empty-state / liveness / strictness-ledger / variant-docs, doc-formula-expressions, adr-0087-registration, changeset-no-major, empty-changeset, dev-prereqs (after full packages build), affected-docs, type-check-coverage, type-check-debt (--re-measure, none above recorded), engine-double-contract, where-matcher, i18n — all green. Beyond the dispatch-time list, the re-derivation added the test-file convention gates (query-options-erasure, type-check-coverage/debt, engine-double-contract, where-matcher) and the changeset gates — all run, all green; check:i18n was named conditionally and measured unmoved (no extractor-visible change).

Generated by Claude Code

claude added 2 commits August 17, 2026 05:25
…med by the inbox channel (#9225)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP
…ract exports

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs18A2DdXLVN2h8PaaFBcP
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/plugin-email, @objectstack/service-messaging, @objectstack/spec, touching 13 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via IEmailService (symbol))
  • content/docs/kernel/contracts/index.mdx (via IEmailService (symbol))
  • content/docs/kernel/index.mdx (via sendTemplate (symbol))
  • content/docs/kernel/runtime-services/email-service.mdx (via sendTemplate (symbol))
  • content/docs/protocol/kernel/plugin-spec.mdx (via EmailService (symbol))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via MessagingServicePlugin (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/api-surface/contracts.json, packages/spec/export-origins/contracts.json) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 114 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json origin/main → packageMentionDocs.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants