# Email / Message Template Module — Implementation Plan **Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Target:** first templates sending by Friday go-live. ## Goal A reusable template module so **any** message the system sends — welcome, payment receipt, onboarding reminders, drip — is produced from a stored (or ad-hoc) template with variables filled in, then handed to the **existing** outbound pipeline. One render path, one send path, provenance recorded for every send. From the July 16 meeting: *"template का एक पूरा module बनाना है… template में value भरोगे, और वो outbound क्यू में डाल दोगे।"* ## What already exists (the substrate — do NOT rebuild) - `OutboundService.send(channelType, target, payload, idempotencyKey?, scopeId?, purpose?)` — idempotency + per-target/per-tenant rate limits + delivery ledger (`IiosOutboundCommand`). - `CapabilityBroker` — policy gate + obligations + provider selection for egress. - `EMAIL` is a registered channel; `EmailProvider` exists (**HTTP**, not SMTP — see Out of Scope). - `EMAIL` is a first-class `IiosInteractionKind`; `IiosMessagePartKind` has `HTML`/`TEXT`. - `IiosActorKind` includes `SERVICE`/`BOT` (system sender is first-class). - `InboxModule` uses `OnModuleInit` — copy that pattern for the template seeder. The module adds **content/rendering** in front of this. It never talks to a provider directly. ## Design decisions (locked) | # | Decision | Rationale | |---|---|---| | 1 | **Channel-generic**: one template per `(key, channel)`; channels `EMAIL` / `SMS` / `INTERNAL` | Vivek wants the same confirmation on email *and* SMS; INTERNAL = app-to-app, no SMTP | | 2 | **Seed-from-files, DB-is-truth**: templates are files in the repo, seeded into the DB on boot if absent | Copy is version-controlled + code-reviewed; a later admin UI can edit the DB with no deploy; Friday needs no UI | | 3 | **Handlebars** rendering | Auto-escapes HTML (customer names go into email → XSS risk), logic-less, no code execution, one dep | | 4 | **Global default + scope override**: `scopeId` nullable — `NULL` = platform default, set = tenant override; resolve scoped-first-else-global | Seeds cleanly at boot (IIOS scopes are created lazily, so a boot seeder has no scope to seed into); leaves room for white-label | | 5 | **Provenance on `IiosOutboundCommand`** (4 columns), not a new `template_snapshot` table | Rendered content is already in `payload`; only provenance is missing. Honours the SOT's intent at 4 columns | | 6 | **`TemplateSource` = stored `{key,version?}` OR `{inline:{subject,html,text}}`** | Marketing hands over finished HTML (*"Maaz, HTML भेज सकते हैं"*); inline still renders vars + records provenance | | 7 | **Integration pattern B**: pure `render()` + thin `sendTemplated()` composer | Single entry point for callers; provenance guaranteed by construction; `OutboundService` stays content-agnostic | | 8 | **Caller = a service** (be-crm's system token); **recipient = a `target` address, never a login** | System mail has no user on the sending side; the recipient may have no account yet | ## Caller & auth model Every send is authenticated as the **calling app/service** (e.g. be-crm via its `APP_SECRETS` entry), verified by `SessionVerifier` like every other IIOS endpoint. The recipient is a plain `target` string — **not** an IIOS principal and **not** required to be logged in or registered. Sending a welcome email to an anonymous payer is the normal case: the app is the sender, the address is data. `scopeId` for the send is derived from the caller's principal (`org/app/tenant`). ## Data model ### New table — `IiosMessageTemplate` ```prisma enum IiosTemplateChannel { EMAIL SMS INTERNAL } /// The template SOURCE. DB is runtime truth; platform defaults are seeded from repo files on boot. /// Versions are immutable: a change writes a new (higher) version, never edits in place. model IiosMessageTemplate { id String @id @default(cuid()) /// NULL = platform default (seeded). Set = a tenant scope's override of the same key. scopeId String? scope IiosScope? @relation(fields: [scopeId], references: [id], onDelete: Cascade) key String // "welcome", "payment.receipt", "onboarding.reminder" channel IiosTemplateChannel locale String @default("en") version Int @default(1) subject String? // EMAIL only bodyHtml String? // EMAIL / INTERNAL bodyText String? // SMS, and EMAIL plaintext fallback /// Declared variable names — render throws if a declared var is missing (fail loud). variables Json? active Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([scopeId, key, channel, locale, version]) @@index([key, channel, locale, active]) } ``` `IiosScope` needs the back-relation `messageTemplates IiosMessageTemplate[]`. **Resolution:** `resolve(key, channel, locale, scopeId?)` returns the highest-`version` `active` row, preferring `scopeId = ` and falling back to `scopeId IS NULL`. No match → 404. ### Provenance columns on `IiosOutboundCommand` ```prisma templateKey String? templateVersion Int? templateLocale String? renderedHash String? // sha256 of the rendered content — replay/audit ``` All nullable: non-templated sends (if any) leave them null. **How provenance is written (review finding):** `OutboundService.send` **creates the command row itself** (both the RATE_LIMITED and PENDING paths), so a caller cannot stamp provenance after the fact without a racy update. Therefore `send()` gains one optional trailing arg — `provenance?: { templateKey; templateVersion; templateLocale; renderedHash }` — written into the same `create()` in both paths. `OutboundService` stays content-agnostic: it does not render or resolve templates, it only persists four opaque strings it is handed. This is what makes decision 7's "provenance guaranteed by construction" true. **This is a change to an existing file** (`adapters/outbound.service.ts`) and its spec — call it out in the PR. ### Migrations (hand-written, non-destructive) 1. `add_message_template` — the enum + table + `IiosScope` back-relation. **Plus a partial unique index for global rows (review finding):** the `@@unique([scopeId, key, channel, locale, version])` does **not** prevent duplicate *platform-default* rows, because Postgres treats `NULL` scopeId as distinct (`NULL != NULL`) — the same footgun handled in the inbox idempotency migration. Add: `CREATE UNIQUE INDEX "IiosMessageTemplate_global_key" ON "IiosMessageTemplate"("key","channel","locale","version") WHERE "scopeId" IS NULL;` so a double-seed or race cannot create two defaults for the same key. 2. `add_outbound_template_provenance` — the 4 nullable columns on `IiosOutboundCommand`. ## Module structure ``` packages/iios-service/src/templates/ template.channel.ts // IiosTemplateChannel re-export/helpers if needed template.model.ts // TemplateSource, RenderedContent, CreateSendInput types template.repository.ts // resolve(): scoped ?? global, highest active version template.renderer.ts // Handlebars compile+render; escaping; missing-var throw template.service.ts // render(source, vars) — pure; resolve + renderer templated-sender.ts // sendTemplated(): render -> OutboundService.send -> stamp provenance template.seeder.ts // OnModuleInit: seed file defaults into DB if (key,channel,locale,version) absent template.controller.ts // POST /v1/templates/send, POST /v1/templates/preview template.dto.ts // SendTemplateDto, PreviewTemplateDto (class-validator) template.module.ts seeds/ welcome.email.ts // { key, channel, locale, version, subject, html, text, variables } payment-receipt.email.ts payment-receipt.sms.ts onboarding-reminder.email.ts templates.spec.ts // TDD, real Postgres (localhost:5434), mirrors inbox.spec.ts ``` **Scope boundary:** `render()` is channel-generic (it can render an `INTERNAL` template to `{subject,html,text}`). `sendTemplated()` covers **external** channels (`EMAIL`/`SMS`) via `OutboundService`. `INTERNAL` *delivery* (render → create an in-app `Interaction`, no SMTP) reuses `render()` but is wired by the messaging/mirror spec — this module never imports the messaging layer. ## Contract ```ts type TemplateSource = | { key: string; version?: number } | { inline: { subject?: string; html?: string; text?: string } }; interface RenderedContent { subject?: string; html?: string; text?: string } // template.renderer.ts — PURE (no I/O): given a template's raw strings + vars, produce content. // template.service.ts render() — resolves the template from the DB (I/O), then calls the pure renderer. // For an inline source there is no DB row, so declared-variable validation is skipped — inline // content is the caller's responsibility; only stored templates enforce their declared `variables`. render(source: TemplateSource, vars: Record, opts: { channel: IiosTemplateChannel; locale?: string; scopeId?: string }): Promise // templated-sender.ts — external egress sendTemplated(input: { source: TemplateSource; channel: 'EMAIL' | 'SMS'; target: string; // email address / phone vars: Record; scopeId?: string; idempotencyKey: string; // REQUIRED — e.g. "receipt:" purpose?: string; }): Promise ``` **HTTP** (`SessionVerifier`-auth'd; `scopeId` from the caller's principal): - `POST /v1/templates/send` → `sendTemplated`. OPA action `iios.template.send`; inline source additionally gated on `iios.template.send.inline` (arbitrary HTML to customers). - `POST /v1/templates/preview` → `render` only, returns `RenderedContent`. No send. For marketing/QA. ## Task-by-task (TDD) Each task: write the failing test → run it (confirm red) → implement → run (green) → commit. **T1 — Renderer (`template.renderer.ts`)** - Tests: substitutes `{{firstName}}`; **escapes `