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):
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):
// 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:
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:
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):
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 edit — render/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 edit — render/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):
Generated — fe/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 edit — fe/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.