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 writebe/config/project.jsonnet (the comms.platform(...) block):

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 writebe/config/inventory_resources/asset.jsonnet (rep + opt-in):

// 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 writebe/comms.py:

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 writebe/inventory/comms_resolvers.py:

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:

Generatedrender/src/_generated/contract.gen.ts (do not edit):

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<RenderContract> call:

Scaffolded, safe to editrender/src/handlers.ts:

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<RenderContract> = { welcome, password_reset, report_pdf };

Each template anchors its context to RenderContract[<name>], so a Pydantic rename regenerates the contract and fails the handler until you update it:

Scaffolded, safe to editrender/src/templates/welcome.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<Entry>(
  "email",
  (context) => `Welcome, ${context.name}!`,                    // subject
  (context, theme, locale) => (                               // body: typed React email
    <Email theme={theme} {...(locale !== undefined ? { locale } : {})}
      preview={`Welcome aboard, ${context.name}`}>
      <Heading level={1}>Welcome, {context.name}!</Heading>
      <Section><Text>Hi {context.name}, glad you're here.</Text></Section>
    </Email>
  ),
);

The "pdf" variant (report_pdf.tsx) renders through @fsh/codegen-render/pdf (Document / Page) — the same sidecar the reports 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):

Generatedfe/src/_generated/fields/comms/comms_templates.gen.tsx (do not edit):

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<TemplateOut>()((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) => <Badge variant="neutral" leftSection={<FSHIcon name={methodIcon(row.method)} />}>{methodLabel(row.method)}</Badge>,
    table: { width: "sm" },
  }),
]);

You write only the page shell over the generated table + actions:

Scaffolded, safe to editfe/src/pages/comms/CommsTemplatesPage.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 (
    <FSHPage title="Templates" actions={commsTemplatesActions.useCollection("all")}>
      <FSHTableView table={table} to={commsTemplatesActions.rowHref} columnEditor
        emptyState={{ title: "No templates yet", description: "Create one to address a resource's creator and attach its reports." }} />
    </FSHPage>
  );
}

Authoring (with the resource-type picker) lives on the generated create / update action 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.