From cea0a27118ca38c9fac8d41d5160bc8b5ded4d11 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 12:18:26 +0530 Subject: [PATCH] docs(templates): email/message template module plan Design + 8-task TDD plan for the IIOS template module. Self-reviewed: provenance-write path, nullable-scope unique index, PII minimization. Co-Authored-By: Claude Opus 4.8 --- docs/email-template-module-plan.md | 277 +++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/email-template-module-plan.md diff --git a/docs/email-template-module-plan.md b/docs/email-template-module-plan.md new file mode 100644 index 0000000..ef5d87e --- /dev/null +++ b/docs/email-template-module-plan.md @@ -0,0 +1,277 @@ +# 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 `