Files
iios/docs/email-template-module-plan.md
maaz519 cea0a27118 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 <noreply@anthropic.com>
2026-07-18 12:18:26 +05:30

278 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = <caller scope>` 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<string, unknown>, opts: { channel: IiosTemplateChannel; locale?: string; scopeId?: string }): Promise<RenderedContent>
// templated-sender.ts — external egress
sendTemplated(input: {
source: TemplateSource;
channel: 'EMAIL' | 'SMS';
target: string; // email address / phone
vars: Record<string, unknown>;
scopeId?: string;
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
purpose?: string;
}): Promise<IiosOutboundCommand>
```
**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 `<script>` in a name**; `{{#if}}`/`{{#each}}`;
a declared-but-missing variable throws; renders `bodyHtml` and `bodyText` independently.
- Impl: Handlebars, `noEscape:false`; validate declared `variables` present.
**T2 — Migrations + repository (`template.repository.ts`)**
- Migration 1 (table+enum). Regenerate client.
- Tests: `resolve` returns highest active version; **scoped overrides global**; unknown key → NotFound;
inactive versions ignored.
**T3 — `render()` service tying resolve+renderer, incl. inline source**
- Tests: stored `{key}` resolves+renders; `{inline}` renders without a DB row; wrong channel → 404.
**T4 — Provenance: `OutboundService.send` param + migration + `sendTemplated()`**
- Migration 2 (4 columns).
- Modify `OutboundService.send` to accept the optional `provenance` arg and write it into the command
`create()` on both the RATE_LIMITED and PENDING paths. Extend `outbound.service.spec.ts`: a send
with provenance persists all four fields; a send without leaves them null (no regression).
- Then `sendTemplated()`. Tests: composes render→`OutboundService.send`; **stamps
`templateKey/version/locale/renderedHash`** on the command; **idempotent per key** (replay → one
`IiosOutboundCommand`); inline → `templateKey` null, `renderedHash` set.
**T5 — Seeder (`template.seeder.ts`, `seeds/*`)**
- Tests: boot seeds the file defaults as `scopeId NULL`; **re-seed is idempotent** (no dupes);
a bumped file version inserts a new row, leaves the old.
**T6 — Controller + DTOs**
- Tests (HTTP, boot against sandbox provider so no real mail): `POST /send` renders+queues;
unknown key → 404; bad body → 400; missing auth → 400/401; `POST /preview` returns content, sends nothing.
**T7 — Whole-suite gate**
- `vitest run` (all packages), `npm run boundary`, `npm run build` all green.
- Manual: boot locally, `POST /v1/templates/send` with the `welcome` seed via sandbox, inspect the
`IiosOutboundCommand` row for payload + provenance.
**T8 — PII redaction of outbound commands (fast-follow; lever #2)**
- Extend `RetentionService` so its sweep also redacts aged `IiosOutboundCommand` rows: raw `target`
and rendered `payload` → redacted, while `templateKey/version/renderedHash` + `scopeId` are kept
for audit. Reuse the existing redact-in-place pattern (currently applied to `iiosMessagePart`).
- Tests: a command past its window has `target`/`payload` redacted but provenance intact; a command
under compliance hold is skipped; audit row `retention.redacted` written.
- Independent of T1T7 — can land immediately after the module without blocking Friday.
## Error handling
- Unknown key/channel/locale → `NotFoundException` (404).
- Declared variable missing → throw (400) — never send a half-rendered receipt.
- Transport failure → surfaced as `FAILED` on the command by `OutboundService`, never thrown to caller.
- Idempotent replay (same key) → returns the existing command; never double-sends.
## Out of scope (separate specs — this module unblocks them)
1. **`SmtpProvider`** (nodemailer, `accounts@`/`ceo@lynkeduppro.com`) in IIOS's capability registry.
Today's `EmailProvider` is HTTP; SMTP is a new provider flipped on by env. Without it, sends land
in the sandbox.
2. **`POST /webhooks/stripe` in be-crm** — verify Stripe signature → mint a **service token**
call `POST /v1/templates/send` (welcome + receipt), keyed on the Stripe object id. This is the
"backend" the frontend-only payment site lacks.
3. **Inbox mirror** (post-registration): render an email → also create an `Interaction(kind=EMAIL)`
so it appears in the customer's in-app inbox. `INTERNAL` delivery lives here. Mirror only *after*
registration (no inbox exists before).
## PII minimization
Sending email means IIOS must *touch* the recipient's address (you can't mail without it) and the
rendered body holds their name — so `IiosOutboundCommand.target`/`payload` become PII. You cannot
avoid IIOS touching it; you avoid it **accumulating** and being **cheaply reachable**. Three levers,
by impact:
1. **Rotate the `Qwerty@a2` signing secret — precondition for prod, highest impact, not code.**
The stored PII is only dangerous because any leaked token can be brute-forced back to the secret,
letting an attacker forge a token and read the outbound table. A strong random secret leaves the
PII in place but **unreachable by forgery** — 90% of the risk closed by one env change. **Gate:
do not point the real SMTP provider at production until this is rotated.**
2. **Redact the raw address + body after delivery (fast-follow task — see T8).**
IIOS keeps `templateKey/version/renderedHash` + the opaque `scopeId` for audit, but the raw
`target` and rendered `payload` are redacted-in-place once past their retention window. The
`RetentionService` already does exactly this for interaction message parts (`bodyText →
'[redacted]'`); it just doesn't cover `IiosOutboundCommand` yet. Reuse the same sweep.
3. **Tokenized recipient (future, when MDM ships).** The clean model is: callers pass a `userId`,
IIOS resolves the address from MDM *at send time* and never persists it. Not available today
(MDM isn't deployed; `externalId` is a UUID with no email). **Design accommodation now:** keep
`target: string` for Friday, but treat it as an opaque "where to send" so it can later become
`{ userId }` resolved via MDM without changing the `sendTemplated` contract shape.
**Rejected shortcut:** having be-crm send SMTP directly so IIOS never sees the address. It "avoids"
the PII but breaks the inbox mirror and the single-send-path (IIOS would have no record to copy into
the inbox) — trading a solvable security problem for a broken feature.
## Other risks
- **Friday, no test env:** first real sends go to paying customers from an unproven path. Insist on a
sandbox target / 100%-off test coupon before wiring the real SMTP provider (compounds with lever #1
above — don't send real mail from prod until the secret is rotated *and* a safe test target exists).