# Emails, comms & templates *Layer: Backend + render sidecar · the comms platform* Typed, template-rendered messages (email, SMS, …) become rows in a transactional outbox that a pgqueuer worker dispatches. Configure the platform once at project scope; resources opt in as user-template targets. ```{note} No HTTP routes are emitted for developer-defined sends — triggering `welcome` or `password_reset` is your concern (an action handler, cron, webhook). Only `user_templates` (and the per-resource `send_action`) add a route surface. ``` ## How it works The platform is a `CommRegistry` + `send_comm(...)` outbox producer + a `dispatch` worker in `_generated/comms.py`, plus the user-template CRUD router. You supply the transport/renderer/resolver *instances* the config's dotted paths point at, the context Pydantic classes, and — under the render sidecar — the typed React templates in the sibling `render/` service. ## The config you write **You write** — `be/config/project.jsonnet` (the `comms.platform(...)` block): ```jsonnet local comms = import "be/comms/comms.libsonnet"; comms: comms.platform({ message_model: "fsh.models.CommMessage", // MessageMixin subclass recipient_model: "fsh.models.CommRecipient", // RecipientMixin subclass transports: { // Transport *instance* per method email: "comms.email_transport", sms: "comms.sms_transport", }, preferences: "comms.resolver", // PreferenceResolver; unset -> no opt-out gating // Render developer-defined types through the codegen-render sidecar instead // of the in-process JinjaRenderer default. The inline subject/body below are // vestigial under this renderer (the render service owns the real markup), // but body_template is a required spec field so they stay as shape docs. renderer: "comms.render_renderer", types: [ comms.type({ name: "welcome", // registry key, stored on the message row context_schema: "comms.WelcomeContext", // Pydantic model the templates render against subject_template: "Welcome, {{ name }}!", body_template: "Hi {{ name }}, glad you're here.", default_methods: ["email"], // intended fan-out (documentation hint) }), comms.type({ name: "password_reset", context_schema: "comms.PasswordResetContext", subject_template: "Reset your password", body_template: "Reset your password: {{ reset_url }}", // vestigial under the sidecar default_methods: ["email"], }), comms.type({ name: "report_pdf", // never emailed: declared only so the render context_schema: "comms.ReportPdfContext", // contract emits a typed report_pdf handler subject_template: "{{ name }}", // the reports platform's render_pdf POSTs to body_template: "Tabular report rendered to PDF.", output: "pdf", // "email" (HTML+text) | "pdf" (base64); default "email" }), ], // Thin CRUD + describe-options + preview + send router at // _generated/comms_user_templates.py -- the only emitted route surface. user_templates: comms.user_templates({ template_model: "fsh.models.UserCommTemplate" }), // Project-wide slots, offered in every template's editor regardless of scope. // Group resolvers take (session, method) -- no `instance`. recipient_groups: { all_staff: comms.recipient_group({ label: "all-staff", description: "Everyone in the company", resolver: "tracker.comms_resolvers.all_staff", }), }, attachment_groups: { weekly_report: comms.attachment_group({ label: "weekly-report", description: "Auto-generated weekly summary PDF", resolver: "tracker.comms_resolvers.weekly_report", }), }, }), ``` A resource opts in as a template target with `comms.target(...)`: **You write** — `be/config/inventory_resources/asset.jsonnet` (rep + opt-in): ```jsonnet // The variable surface: these fields become the pills a template author drops // into a rendered subject / body. A plain representation -- see fields.md. { name: "comms_summary", exclude_actions: true, fields: [ fields.id(), { name: "name", type: "str" }, { name: "status", type: "enum", enum: "inventory.models.AssetStatus" }, // Joined creator -> exposes {created_by.username} / .email as variables. fields.nested("created_by", "fsh.models.auth.user.User", [ { name: "username", type: "str" }, { name: "email", type: "str", nullable: true }, ], load="joined", nullable=true), ], }, comms_target: comms.target({ representation: "comms_summary", // names the rep above; its fields = the pills recipient_resolvers: { // slot -> (session, instance, method) -> [RecipientSpec] created_by: "inventory.comms_resolvers.asset_created_by", }, // (session) -> [AttachmentSuggestion]; fills the editor's attachment lane with // THIS resource's reports instead of the project-wide attachment groups. attachment_suggestions: "inventory.comms_resolvers.asset_report_attachments", // Synthesize POST /assets/{id}/send-email. true -> codegen owns the handler // (forwards to fsh_lib.comms.send_composed); dotted str -> you own it; omit -> none. // Requires user_templates. send_action: true, }), ``` The dotted paths resolve into `be/comms.py` — transports and renderer are *instances* built from real `fsh_lib.comms` utilities: **You write** — `be/comms.py`: ```python import os from fsh_lib.comms import HttpRenderer, LoggingTransport # LoggingTransport records sends into `.sent` instead of hitting SMTP/Twilio; # swap for a real adapter in production -- call sites + dispatch don't change. email_transport: Transport = LoggingTransport() sms_transport: Transport = LoggingTransport() # POSTs {template, context, theme} to the sibling render/ service; maps the # response to HTML+text (email) or base64 bytes (pdf). render_renderer: Renderer = HttpRenderer( base_url=os.environ.get("RENDER_URL", "http://localhost:8200"), ) ``` Recipient resolvers return `fsh_lib.comms.RecipientSpec` rows; per-resource ones take `instance`, project-wide group resolvers don't: **You write** — `be/inventory/comms_resolvers.py`: ```python from fsh_lib.comms import RecipientSpec async def asset_created_by( *, session: AsyncSession, instance: Asset, method: str, ) -> Sequence[RecipientSpec]: if instance.created_by_user_id is None: return [] row = (await session.execute( select(User.username, User.email).where(User.id == instance.created_by_user_id), )).one_or_none() if row is None or row.email is None: return [] # unaddressable slot contributes nothing # subject_key = username, so the PreferenceResolver can gate this delivery. return [RecipientSpec(method=method, address=row.email, subject_key=row.username)] ``` ## The frontend codegen emits Every BE comm type flows into a typed `RenderContract` the render sidecar builds against — regenerated each run: **Generated** — `render/src/_generated/contract.gen.ts` (do not edit): ```typescript export interface RenderContract { readonly password_reset: { readonly output: "email"; readonly context: PasswordResetContext }; readonly report_pdf: { readonly output: "pdf"; readonly context: ReportPdfContext }; readonly welcome: { readonly output: "email"; readonly context: WelcomeContext }; } ``` The `handlers` barrel is scaffolded once; every BE type needs an entry or `tsc --noEmit` fails at the `startServer` call: **Scaffolded, safe to edit** — `render/src/handlers.ts`: ```typescript import { password_reset } from "./templates/password_reset.js"; import { report_pdf } from "./templates/report_pdf.js"; import { welcome } from "./templates/welcome.js"; import type { RenderContract } from "./_generated/contract.gen.js"; import type { HandlerBundles } from "@fsh/codegen-render"; export const handlers: HandlerBundles = { welcome, password_reset, report_pdf }; ``` Each template anchors its context to `RenderContract[]`, so a Pydantic rename regenerates the contract and fails the handler until you update it: **Scaffolded, safe to edit** — `render/src/templates/welcome.tsx`: ```tsx import { defineRenderer } from "@fsh/codegen-render"; import { Email, Heading, Section, Text } from "@fsh/codegen-render/email"; import type { RenderContract } from "../_generated/contract.gen.js"; type Entry = RenderContract["welcome"]; // typed-contract safety net export const welcome = defineRenderer( "email", (context) => `Welcome, ${context.name}!`, // subject (context, theme, locale) => ( // body: typed React email Welcome, {context.name}!
Hi {context.name}, glad you're here.
), ); ``` The `"pdf"` variant (`report_pdf.tsx`) renders through `@fsh/codegen-render/pdf` (`Document` / `Page`) — the same sidecar the [reports](reports.md) platform uses. User-authored templates surface as a first-class FE resource. The field catalog, table, and actions are fully generated (templates are a fixed platform model): **Generated** — `fe/src/_generated/fields/comms/comms_templates.gen.tsx` (do not edit): ```tsx import { Badge, defineFields, FSHIcon } from "@fsh/components-library"; import { methodIcon, methodLabel } from "@fsh/components-library/editor"; import { targetResourceLabel } from "../../tables/comms/comms_templates.gen"; import type { TemplateOut } from "../../types/common.gen"; export const commsTemplatesFields = defineFields()((field) => [ field("name", { header: "Name", cell: (row) => row.name, table: { rowHeader: true, grow: true, sortable: true } }), field("target_resource", { // the scoped resource ("type") header: "Type", cell: (row) => targetResourceLabel(row.target_resource), table: { width: "sm", sortable: true }, }), field("method", { // friendly transport badge, not the raw slug header: "Method", cell: (row) => }>{methodLabel(row.method)}, table: { width: "sm" }, }), ]); ``` You write only the page shell over the generated table + actions: **Scaffolded, safe to edit** — `fe/src/pages/comms/CommsTemplatesPage.tsx`: ```tsx import { FSHPage, FSHTableView } from "@fsh/components-library"; import { commsTemplatesActions } from "../../_generated/actions/comms/comms_templates_defs.gen"; import { commsTemplatesFields } from "../../_generated/fields/comms/comms_templates.gen"; import { useCommsTemplatesTable } from "../../_generated/tables/comms/comms_templates.gen"; export default function CommsTemplatesPage() { const table = useCommsTemplatesTable({ columns: [ commsTemplatesFields.columns.name, commsTemplatesFields.columns.target_resource, commsTemplatesFields.columns.method, ], filters: "all", // generated Type (server) + Method (client) chips }); return ( ); } ``` Authoring (with the resource-type picker) lives on the generated `create` / `update` [action](actions.md) overlays. *Sending* isn't here — it's each target resource's synthesized `send_email` object action, since a template only resolves against a concrete row. Columns, sorting, and gating are [table configuration](table-configuration.md).