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>
16 KiB
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.EMAILis a registered channel;EmailProviderexists (HTTP, not SMTP — see Out of Scope).EMAILis a first-classIiosInteractionKind;IiosMessagePartKindhasHTML/TEXT.IiosActorKindincludesSERVICE/BOT(system sender is first-class).InboxModuleusesOnModuleInit— 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
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
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)
add_message_template— the enum + table +IiosScopeback-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 treatsNULLscopeId 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.add_outbound_template_provenance— the 4 nullable columns onIiosOutboundCommand.
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
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 actioniios.template.send; inline source additionally gated oniios.template.send.inline(arbitrary HTML to customers).POST /v1/templates/preview→renderonly, returnsRenderedContent. 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; rendersbodyHtmlandbodyTextindependently. - Impl: Handlebars,
noEscape:false; validate declaredvariablespresent.
T2 — Migrations + repository (template.repository.ts)
- Migration 1 (table+enum). Regenerate client.
- Tests:
resolvereturns 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.sendto accept the optionalprovenancearg and write it into the commandcreate()on both the RATE_LIMITED and PENDING paths. Extendoutbound.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; stampstemplateKey/version/locale/renderedHashon the command; idempotent per key (replay → oneIiosOutboundCommand); inline →templateKeynull,renderedHashset.
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 /sendrenders+queues; unknown key → 404; bad body → 400; missing auth → 400/401;POST /previewreturns content, sends nothing.
T7 — Whole-suite gate
vitest run(all packages),npm run boundary,npm run buildall green.- Manual: boot locally,
POST /v1/templates/sendwith thewelcomeseed via sandbox, inspect theIiosOutboundCommandrow for payload + provenance.
T8 — PII redaction of outbound commands (fast-follow; lever #2)
- Extend
RetentionServiceso its sweep also redacts agedIiosOutboundCommandrows: rawtargetand renderedpayload→ redacted, whiletemplateKey/version/renderedHash+scopeIdare kept for audit. Reuse the existing redact-in-place pattern (currently applied toiiosMessagePart). - Tests: a command past its window has
target/payloadredacted but provenance intact; a command under compliance hold is skipped; audit rowretention.redactedwritten. - 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
FAILEDon the command byOutboundService, never thrown to caller. - Idempotent replay (same key) → returns the existing command; never double-sends.
Out of scope (separate specs — this module unblocks them)
SmtpProvider(nodemailer,accounts@/ceo@lynkeduppro.com) in IIOS's capability registry. Today'sEmailProvideris HTTP; SMTP is a new provider flipped on by env. Without it, sends land in the sandbox.POST /webhooks/stripein be-crm — verify Stripe signature → mint a service token → callPOST /v1/templates/send(welcome + receipt), keyed on the Stripe object id. This is the "backend" the frontend-only payment site lacks.- Inbox mirror (post-registration): render an email → also create an
Interaction(kind=EMAIL)so it appears in the customer's in-app inbox.INTERNALdelivery 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:
-
Rotate the
Qwerty@a2signing 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. -
Redact the raw address + body after delivery (fast-follow task — see T8). IIOS keeps
templateKey/version/renderedHash+ the opaquescopeIdfor audit, but the rawtargetand renderedpayloadare redacted-in-place once past their retention window. TheRetentionServicealready does exactly this for interaction message parts (bodyText → '[redacted]'); it just doesn't coverIiosOutboundCommandyet. Reuse the same sweep. -
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;externalIdis a UUID with no email). Design accommodation now: keeptarget: stringfor Friday, but treat it as an opaque "where to send" so it can later become{ userId }resolved via MDM without changing thesendTemplatedcontract 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).