cea0a27118
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>
278 lines
16 KiB
Markdown
278 lines
16 KiB
Markdown
# 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 T1–T7 — 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).
|