diff --git a/docs/email-attachments-plan.md b/docs/email-attachments-plan.md new file mode 100644 index 0000000..855d304 --- /dev/null +++ b/docs/email-attachments-plan.md @@ -0,0 +1,67 @@ +# Email Attachments — Implementation Plan + +**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · Extends the SMTP provider + mail plumbing. + +## Goal + +Let an external email carry attachments (e.g. an invoice PDF). The kernel already STORES attachments +as message parts (`contentRef` + mime + size); the media `StoragePort` holds the bytes. The one gap is +the **email envelope** — `SmtpProvider` (and the payload) don't carry attachments. Close that. + +## Design (locked) + +- **Payload carries REFS, not bytes:** the EMAIL payload gains + `attachments?: [{ filename, contentRef, mimeType? }]`. Refs keep the outbound-command ledger small + (and keep T8's PII redaction cheap) — bytes are fetched at send time. +- **Resolver seam:** `AttachmentResolver = (contentRef) => Promise<{ filename?; content: Buffer; contentType? } | null>`. + `SmtpProvider` takes an optional resolver; on send it resolves each ref and attaches + (nodemailer `attachments: [{ filename, content, contentType }]`). +- **Fail closed on a missing attachment:** if a declared attachment can't be resolved, the send is + `FAILED` (so it retries) — NOT sent without it. A receipt/invoice missing its file is worse than a + retry. (No resolver wired at all + attachments present → also FAILED, same reasoning.) +- **Wiring:** `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; + `CapabilityProviderRegistry` `@Optional() @Inject(STORAGE_PORT)` → builds the resolver from + `storage.get(contentRef)` → passes it to `SmtpProvider`. `@Optional` so contexts without storage + still boot (attachments simply can't resolve → FAILED if any are declared). +- **Scope:** SMTP path only. The HTTP relay `EmailProvider` attachment support is a separate follow-up + (it would base64 the bytes into the relay POST). INTERNAL/mirror attachments already work via message + parts and are not this plan. + +## Files + +``` +src/capability/smtp.provider.ts # attachments in EmailPayload + resolve+attach in send() +src/capability/smtp.provider.spec.ts # attach resolved bytes; missing → FAILED +src/capability/capability.registry.ts # inject STORAGE_PORT → resolver → SmtpProvider +src/capability/capability.module.ts # import MediaModule +src/media/media.module.ts # export STORAGE_PORT +src/templates/templated-sender.ts # accept + pass `attachments` +src/mail/mail.service.ts # accept + pass `attachments` (external send) +``` + +## Tasks (TDD) + +**T1 — SmtpProvider attaches / fails closed** +- Inject a stub resolver. Tests: two refs → nodemailer `attachments` has both (filename + content + + contentType); a ref the resolver returns `null` for → outcome `FAILED`, nothing sent; no attachments + in payload → unchanged (plain send still SENT). + +**T2 — registry wires the resolver from STORAGE_PORT** +- `MediaModule` exports `STORAGE_PORT`; `CapabilityModule` imports `MediaModule`; registry injects it + `@Optional`. Test: with a fake storage bound, `forChannel('EMAIL')` SMTP resolves an attachment; + without storage, the registry still constructs (attachments would FAIL, but boot is fine). + +**T3 — pass-through: TemplatedSender + MailService** +- `sendTemplated`/`sendExternalWithMirror` accept `attachments` and place them in the EMAIL payload. + Tests: the outbound command's payload carries the attachment refs. + +**T4 — gate + real send** +- Full suite + boundary + build. Manual: a real Ethereal send with a small attachment → SENT, and the + Ethereal message shows the attachment. + +## Risks +- **Fail-closed is deliberate** — don't silently send an invoice email without the invoice. +- **Payload holds refs, not bytes** — so the ledger and T8 redaction stay small; the resolver reads + bytes only at send time. +- **`@Optional` storage** — a context without `STORAGE_PORT` boots fine but can't send attachments; + that's correct (fail-closed), not a silent drop. 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 `' }, + ); + expect(out.html).toBe('

<script>alert(1)</script>

'); + expect(out.html).not.toContain(''); // plaintext is not HTML → not escaped + }); + + it('supports {{#if}} and {{#each}}', () => { + const out = renderTemplate( + { bodyText: '{{#if companyName}}Company: {{companyName}}\n{{/if}}{{#each items}}- {{this}}\n{{/each}}', variables: [] }, + { companyName: 'Acme', items: ['a', 'b'] }, + ); + expect(out.text).toBe('Company: Acme\n- a\n- b\n'); + }); + + it('throws when a declared variable is missing (fail loud)', () => { + expect(() => renderTemplate({ bodyText: 'Hi {{firstName}}', variables: ['firstName'] }, {})).toThrow(MissingTemplateVariableError); + }); + + it('renders only the parts that are present', () => { + const out = renderTemplate({ bodyText: 'code {{code}}', variables: ['code'] }, { code: '123' }); + expect(out.text).toBe('code 123'); + expect(out.subject).toBeUndefined(); + expect(out.html).toBeUndefined(); + }); +}); diff --git a/packages/iios-service/src/templates/template.renderer.ts b/packages/iios-service/src/templates/template.renderer.ts new file mode 100644 index 0000000..5668f59 --- /dev/null +++ b/packages/iios-service/src/templates/template.renderer.ts @@ -0,0 +1,46 @@ +import Handlebars from 'handlebars'; + +/** The raw, unrendered strings of a template (from the DB row or an inline source). */ +export interface RawTemplate { + subject?: string | null; + bodyHtml?: string | null; + bodyText?: string | null; + /** Declared variable names. Every one MUST be supplied at render time (fail loud). */ + variables?: string[] | null; +} + +export interface RenderedContent { + subject?: string; + html?: string; + text?: string; +} + +/** A declared variable was not supplied — we refuse to send a half-rendered "Hi ," message. */ +export class MissingTemplateVariableError extends Error { + constructor(public readonly missing: string[]) { + super(`missing required template variable(s): ${missing.join(', ')}`); + this.name = 'MissingTemplateVariableError'; + } +} + +/** + * Pure render: interpolate `vars` into a template's strings. HTML output is auto-escaped (values + * like a customer name go into an email body — XSS risk); subject and plaintext are NOT escaped. + * No I/O — the caller supplies the raw template. Throws if any declared variable is absent. + */ +export function renderTemplate(tpl: RawTemplate, vars: Record): RenderedContent { + const declared = tpl.variables ?? []; + const missing = declared.filter((name) => vars[name] === undefined || vars[name] === null); + if (missing.length > 0) throw new MissingTemplateVariableError(missing); + + const out: RenderedContent = {}; + if (tpl.subject != null) out.subject = compile(tpl.subject, false)(vars); + if (tpl.bodyHtml != null) out.html = compile(tpl.bodyHtml, true)(vars); + if (tpl.bodyText != null) out.text = compile(tpl.bodyText, false)(vars); + return out; +} + +/** `escape=true` → HTML-escape interpolated values (for the html body); false → verbatim. */ +function compile(src: string, escape: boolean): Handlebars.TemplateDelegate { + return Handlebars.compile(src, { noEscape: !escape, strict: false }); +} diff --git a/packages/iios-service/src/templates/template.repository.spec.ts b/packages/iios-service/src/templates/template.repository.spec.ts new file mode 100644 index 0000000..1e10040 --- /dev/null +++ b/packages/iios-service/src/templates/template.repository.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateNotFoundError, TemplateRepository } from './template.repository'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const repo = new TemplateRepository(prisma as unknown as PrismaService); + +async function scope(): Promise { + const s = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } }); + return s.id; +} +async function seed(data: Partial[0]['data']> & { key: string }) { + return prisma.iiosMessageTemplate.create({ + data: { channel: 'EMAIL', locale: 'en', version: 1, bodyText: 't', ...data } as never, + }); +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateRepository.resolve', () => { + it('returns the global default when no scope override exists', async () => { + await seed({ key: 'welcome', bodyText: 'global' }); + const t = await repo.resolve('welcome', 'EMAIL', 'en', await scope()); + expect(t.bodyText).toBe('global'); + expect(t.scopeId).toBeNull(); + }); + + it('prefers the scoped override over the global default', async () => { + const scopeId = await scope(); + await seed({ key: 'welcome', bodyText: 'global' }); // scopeId NULL + await seed({ key: 'welcome', bodyText: 'scoped', scopeId }); // override + const t = await repo.resolve('welcome', 'EMAIL', 'en', scopeId); + expect(t.bodyText).toBe('scoped'); + expect(t.scopeId).toBe(scopeId); + }); + + it('returns the highest active version', async () => { + await seed({ key: 'welcome', version: 1, bodyText: 'v1' }); + await seed({ key: 'welcome', version: 3, bodyText: 'v3' }); + await seed({ key: 'welcome', version: 2, bodyText: 'v2' }); + const t = await repo.resolve('welcome', 'EMAIL', 'en'); + expect(t.version).toBe(3); + }); + + it('ignores inactive versions (a retracted v3 falls back to v2)', async () => { + await seed({ key: 'welcome', version: 2, bodyText: 'v2' }); + await seed({ key: 'welcome', version: 3, bodyText: 'v3', active: false }); + const t = await repo.resolve('welcome', 'EMAIL', 'en'); + expect(t.version).toBe(2); + }); + + it('throws TemplateNotFoundError for an unknown key', async () => { + await expect(repo.resolve('nope', 'EMAIL', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError); + }); + + it('does not cross channels (an EMAIL template is not an SMS template)', async () => { + await seed({ key: 'welcome', channel: 'EMAIL' }); + await expect(repo.resolve('welcome', 'SMS', 'en')).rejects.toBeInstanceOf(TemplateNotFoundError); + }); +}); diff --git a/packages/iios-service/src/templates/template.repository.ts b/packages/iios-service/src/templates/template.repository.ts new file mode 100644 index 0000000..248ad0a --- /dev/null +++ b/packages/iios-service/src/templates/template.repository.ts @@ -0,0 +1,49 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { IiosMessageTemplate, IiosTemplateChannel } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +export class TemplateNotFoundError extends NotFoundException { + constructor(key: string, channel: string, locale: string) { + super(`no active template for key="${key}" channel=${channel} locale=${locale}`); + this.name = 'TemplateNotFoundError'; + } +} + +@Injectable() +export class TemplateRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * Resolve the template to use: the highest active version for (key, channel, locale), preferring + * a row owned by `scopeId` (a tenant override) and falling back to the global default (scopeId + * NULL). Throws if neither exists. + */ + async resolve( + key: string, + channel: IiosTemplateChannel, + locale = 'en', + scopeId?: string, + version?: number, + ): Promise { + // Scoped override first, then the global default — for both the pinned and highest-active paths. + if (scopeId) { + const scoped = await this.pick({ key, channel, locale, scopeId }, version); + if (scoped) return scoped; + } + const global = await this.pick({ key, channel, locale, scopeId: null }, version); + if (global) return global; + throw new TemplateNotFoundError(key, channel, locale); + } + + /** A pinned `version` fetches that exact version (the caller was explicit); otherwise the highest + * active version — a retracted (inactive) newest version falls back to the last good one. */ + private pick( + where: { key: string; channel: IiosTemplateChannel; locale: string; scopeId: string | null }, + version?: number, + ) { + return this.prisma.iiosMessageTemplate.findFirst({ + where: version != null ? { ...where, version } : { ...where, active: true }, + orderBy: { version: 'desc' }, + }); + } +} diff --git a/packages/iios-service/src/templates/template.seeder.spec.ts b/packages/iios-service/src/templates/template.seeder.spec.ts new file mode 100644 index 0000000..3459a5c --- /dev/null +++ b/packages/iios-service/src/templates/template.seeder.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateSeeder } from './template.seeder'; +import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const seeder = new TemplateSeeder(prisma as unknown as PrismaService); + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateSeeder', () => { + it('seeds the file defaults as global (scopeId NULL) templates', async () => { + const n = await seeder.seed(TEMPLATE_SEEDS); + expect(n).toBe(TEMPLATE_SEEDS.length); + const rows = await prisma.iiosMessageTemplate.findMany(); + expect(rows).toHaveLength(TEMPLATE_SEEDS.length); + expect(rows.every((r) => r.scopeId === null)).toBe(true); + const welcome = rows.find((r) => r.key === 'welcome' && r.channel === 'EMAIL'); + expect(welcome?.variables).toEqual(['firstName', 'signupLink']); + }); + + it('is idempotent — re-seeding inserts nothing and creates no duplicates', async () => { + await seeder.seed(TEMPLATE_SEEDS); + const second = await seeder.seed(TEMPLATE_SEEDS); + expect(second).toBe(0); + expect(await prisma.iiosMessageTemplate.count()).toBe(TEMPLATE_SEEDS.length); + }); + + it('a bumped version inserts a new row and leaves the old one', async () => { + await seeder.seed(TEMPLATE_SEEDS); + const bumped: TemplateSeed = { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'v2', bodyText: 'v2', variables: [] }; + const n = await seeder.seed([bumped]); + expect(n).toBe(1); + const welcomes = await prisma.iiosMessageTemplate.findMany({ where: { key: 'welcome', channel: 'EMAIL' }, orderBy: { version: 'asc' } }); + expect(welcomes.map((w) => w.version)).toEqual([1, 2]); + }); +}); diff --git a/packages/iios-service/src/templates/template.seeder.ts b/packages/iios-service/src/templates/template.seeder.ts new file mode 100644 index 0000000..8412edf --- /dev/null +++ b/packages/iios-service/src/templates/template.seeder.ts @@ -0,0 +1,49 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { TEMPLATE_SEEDS, type TemplateSeed } from './seeds'; + +/** + * Seeds platform-default templates (scopeId NULL) from the repo files on boot — DB is runtime truth, + * files are the version-controlled source. Idempotent: a seed is inserted only if its exact + * (key, channel, locale, version) default is absent, so re-boots never duplicate and a bumped + * version adds a new row while leaving the old one for audit/replay. + */ +@Injectable() +export class TemplateSeeder implements OnModuleInit { + private readonly log = new Logger(TemplateSeeder.name); + + constructor(private readonly prisma: PrismaService) {} + + async onModuleInit(): Promise { + const inserted = await this.seed(TEMPLATE_SEEDS); + if (inserted > 0) this.log.log(`seeded ${inserted} default template(s)`); + } + + /** Returns how many rows were newly inserted. */ + async seed(seeds: TemplateSeed[]): Promise { + let inserted = 0; + for (const s of seeds) { + const exists = await this.prisma.iiosMessageTemplate.findFirst({ + where: { scopeId: null, key: s.key, channel: s.channel, locale: s.locale, version: s.version }, + select: { id: true }, + }); + if (exists) continue; + await this.prisma.iiosMessageTemplate.create({ + data: { + scopeId: null, + key: s.key, + channel: s.channel, + locale: s.locale, + version: s.version, + subject: s.subject, + bodyHtml: s.bodyHtml, + bodyText: s.bodyText, + variables: s.variables as Prisma.InputJsonValue, + }, + }); + inserted++; + } + return inserted; + } +} diff --git a/packages/iios-service/src/templates/template.service.spec.ts b/packages/iios-service/src/templates/template.service.spec.ts new file mode 100644 index 0000000..e3b171d --- /dev/null +++ b/packages/iios-service/src/templates/template.service.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { resetDb } from '../test-utils/reset-db'; +import { TemplateRepository } from './template.repository'; +import { TemplateService } from './template.service'; +import { TemplateNotFoundError } from './template.repository'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const svc = new TemplateService(new TemplateRepository(prisma as unknown as PrismaService)); + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplateService.render', () => { + it('resolves a stored template and renders it, with provenance', async () => { + await prisma.iiosMessageTemplate.create({ + data: { key: 'welcome', channel: 'EMAIL', locale: 'en', version: 2, subject: 'Hi {{firstName}}', bodyHtml: '

{{firstName}}

', variables: ['firstName'] }, + }); + const { content, provenance } = await svc.render({ key: 'welcome' }, { firstName: 'Dana' }, { channel: 'EMAIL' }); + expect(content.subject).toBe('Hi Dana'); + expect(content.html).toBe('

Dana

'); + expect(provenance).toMatchObject({ templateKey: 'welcome', templateVersion: 2, templateLocale: 'en' }); + expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('renders an inline source without a DB row (key/version null)', async () => { + const { content, provenance } = await svc.render( + { inline: { subject: 'Ad-hoc {{x}}', html: '{{x}}', variables: ['x'] } }, + { x: 'Y' }, + { channel: 'EMAIL' }, + ); + expect(content.subject).toBe('Ad-hoc Y'); + expect(content.html).toBe('Y'); + expect(provenance.templateKey).toBeNull(); + expect(provenance.templateVersion).toBeNull(); + expect(provenance.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('pins to an explicit version when asked', async () => { + await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 1, bodyText: 'v1' } }); + await prisma.iiosMessageTemplate.create({ data: { key: 'k', channel: 'EMAIL', locale: 'en', version: 2, bodyText: 'v2' } }); + const latest = await svc.render({ key: 'k' }, {}, { channel: 'EMAIL' }); + const pinned = await svc.render({ key: 'k', version: 1 }, {}, { channel: 'EMAIL' }); + expect(latest.content.text).toBe('v2'); + expect(pinned.content.text).toBe('v1'); + expect(pinned.provenance.templateVersion).toBe(1); + }); + + it('throws for an unknown stored key', async () => { + await expect(svc.render({ key: 'missing' }, {}, { channel: 'EMAIL' })).rejects.toBeInstanceOf(TemplateNotFoundError); + }); +}); diff --git a/packages/iios-service/src/templates/template.service.ts b/packages/iios-service/src/templates/template.service.ts new file mode 100644 index 0000000..4ed9bc5 --- /dev/null +++ b/packages/iios-service/src/templates/template.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import { TemplateRepository } from './template.repository'; +import { renderTemplate, type RawTemplate, type RenderedContent } from './template.renderer'; +import { isInlineSource, type RenderOptions, type TemplateSource } from './template.model'; + +/** The provenance of a rendered send — carried onto the outbound command for replay/audit. */ +export interface RenderProvenance { + templateKey: string | null; // null for inline sources + templateVersion: number | null; + templateLocale: string | null; + renderedHash: string; // sha256 of the rendered content +} + +export interface RenderResult { + content: RenderedContent; + provenance: RenderProvenance; +} + +@Injectable() +export class TemplateService { + constructor(private readonly repo: TemplateRepository) {} + + /** + * Resolve (if a stored key) and render a template with `vars`, returning the content plus the + * provenance to record on the send. Inline sources skip the DB and carry null key/version — the + * caller owns inline content, so no declared-variable contract is enforced for it. + */ + async render(source: TemplateSource, vars: Record, opts: RenderOptions): Promise { + const locale = opts.locale ?? 'en'; + + if (isInlineSource(source)) { + const raw: RawTemplate = { + subject: source.inline.subject, + bodyHtml: source.inline.html, + bodyText: source.inline.text, + variables: source.inline.variables ?? [], + }; + const content = renderTemplate(raw, vars); + return { content, provenance: this.provenance(content, null, null, null) }; + } + + const tpl = await this.repo.resolve(source.key, opts.channel, locale, opts.scopeId, source.version); + const content = renderTemplate( + { subject: tpl.subject, bodyHtml: tpl.bodyHtml, bodyText: tpl.bodyText, variables: (tpl.variables as string[] | null) ?? [] }, + vars, + ); + return { content, provenance: this.provenance(content, tpl.key, tpl.version, tpl.locale) }; + } + + private provenance(content: RenderedContent, key: string | null, version: number | null, locale: string | null): RenderProvenance { + const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex'); + return { templateKey: key, templateVersion: version, templateLocale: locale, renderedHash: hash }; + } +} diff --git a/packages/iios-service/src/templates/templated-sender.spec.ts b/packages/iios-service/src/templates/templated-sender.spec.ts new file mode 100644 index 0000000..fc2d6b5 --- /dev/null +++ b/packages/iios-service/src/templates/templated-sender.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { resetDb } from '../test-utils/reset-db'; +import { OutboundService } from '../adapters/outbound.service'; +import { CapabilityBroker } from '../capability/capability.broker'; +import { CapabilityProviderRegistry } from '../capability/capability.registry'; +import { IdempotencyService } from '../idempotency/idempotency.service'; +import { TemplateRepository } from './template.repository'; +import { TemplateService } from './template.service'; +import { TemplatedSender } from './templated-sender'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const asService = prisma as unknown as PrismaService; + +function sender(): TemplatedSender { + const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)); + const templates = new TemplateService(new TemplateRepository(asService)); + return new TemplatedSender(templates, outbound, makeFakePorts()); +} + +async function seedReceipt() { + await prisma.iiosMessageTemplate.create({ + data: { key: 'payment.receipt', channel: 'EMAIL', locale: 'en', version: 1, subject: 'Thanks {{firstName}}', bodyHtml: '

{{amount}}

', bodyText: '{{amount}}', variables: ['firstName', 'amount'] }, + }); +} + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('TemplatedSender.sendTemplated', () => { + it('renders a stored template, queues an outbound command, and stamps provenance', async () => { + await seedReceipt(); + const cmd = await sender().sendTemplated({ + source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com', + vars: { firstName: 'Dana', amount: '$2,000' }, idempotencyKey: 'receipt:cs_1', + }); + expect(cmd.channelType).toBe('EMAIL'); + expect(cmd.target).toBe('dana@acme.com'); + expect((cmd.payload as { subject: string }).subject).toBe('Thanks Dana'); + expect(cmd.templateKey).toBe('payment.receipt'); + expect(cmd.templateVersion).toBe(1); + expect(cmd.templateLocale).toBe('en'); + expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('is idempotent per key: a replay yields ONE command', async () => { + await seedReceipt(); + const s = sender(); + const input = { source: { key: 'payment.receipt' }, channel: 'EMAIL' as const, target: 'dana@acme.com', vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'receipt:cs_dup' }; + const a = await s.sendTemplated(input); + const b = await s.sendTemplated(input); + expect(b.id).toBe(a.id); + expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'receipt:cs_dup' } })).toBe(1); + }); + + it('sends inline content with null template key but a hash', async () => { + const cmd = await sender().sendTemplated({ + source: { inline: { subject: 'Hi {{n}}', html: '{{n}}', variables: ['n'] } }, + channel: 'EMAIL', target: 'x@y.com', vars: { n: 'Z' }, idempotencyKey: 'inline:1', + }); + expect((cmd.payload as { subject: string }).subject).toBe('Hi Z'); + expect(cmd.templateKey).toBeNull(); + expect(cmd.renderedHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('carries attachment refs into the EMAIL command payload', async () => { + await seedReceipt(); + const cmd = await sender().sendTemplated({ + source: { key: 'payment.receipt' }, channel: 'EMAIL', target: 'dana@acme.com', + vars: { firstName: 'Dana', amount: '$1' }, idempotencyKey: 'att:1', + attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }], + }); + expect((cmd.payload as { attachments: unknown[] }).attachments).toEqual([{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }]); + }); + + it('shapes an SMS send as text only', async () => { + await prisma.iiosMessageTemplate.create({ + data: { key: 'payment.receipt', channel: 'SMS', locale: 'en', version: 1, bodyText: 'Paid {{amount}}', variables: ['amount'] }, + }); + const cmd = await sender().sendTemplated({ + source: { key: 'payment.receipt' }, channel: 'SMS', target: '+1415', vars: { amount: '$2,000' }, idempotencyKey: 'sms:1', + }); + expect(cmd.channelType).toBe('SMS'); + expect(cmd.payload).toEqual({ text: 'Paid $2,000' }); + }); +}); diff --git a/packages/iios-service/src/templates/templated-sender.ts b/packages/iios-service/src/templates/templated-sender.ts new file mode 100644 index 0000000..3d8acaf --- /dev/null +++ b/packages/iios-service/src/templates/templated-sender.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { IiosPlatformPorts } from '@insignia/iios-contracts'; +import { OutboundService } from '../adapters/outbound.service'; +import { PLATFORM_PORTS } from '../platform/platform-ports'; +import { decideOrThrow } from '../platform/fail-closed'; +import { TemplateService } from './template.service'; +import type { RenderedContent } from './template.renderer'; +import { isInlineSource, type TemplateSource } from './template.model'; + +/** External-egress channels only. INTERNAL (in-app, no SMTP) delivery is the messaging module's job. */ +export type ExternalChannel = 'EMAIL' | 'SMS'; + +/** An email attachment REF (bytes resolved by the provider at send time). */ +export interface AttachmentRef { filename?: string; contentRef: string; mimeType?: string } + +export interface SendTemplatedInput { + source: TemplateSource; + channel: ExternalChannel; + target: string; // email address / phone number + vars: Record; + scopeId?: string; + locale?: string; + idempotencyKey: string; // REQUIRED — e.g. "receipt:" + purpose?: string; + attachments?: AttachmentRef[]; // EMAIL only +} + +/** + * The single entry point for a templated external send: render → OutboundService.send → provenance + * stamped in one place (by construction, not caller convention). Rendering stays pure/testable; + * OutboundService stays content-agnostic (it only records the provenance it is handed). + */ +@Injectable() +export class TemplatedSender { + constructor( + private readonly templates: TemplateService, + private readonly outbound: OutboundService, + @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, + ) {} + + async sendTemplated(input: SendTemplatedInput) { + // Inline HTML to a customer is more dangerous than a reviewed stored template — gate it apart, + // so policy can permit stored sends while restricting ad-hoc ones. Fail-closed. + const action = isInlineSource(input.source) ? 'iios.template.send.inline' : 'iios.template.send'; + await decideOrThrow(this.ports, { action, scopeId: input.scopeId, channel: input.channel }); + + const { content, provenance } = await this.templates.render(input.source, input.vars, { + channel: input.channel, + locale: input.locale, + scopeId: input.scopeId, + }); + return this.outbound.send( + input.channel, + input.target, + this.toPayload(input.channel, content, input.attachments), + input.idempotencyKey, + input.scopeId, + input.purpose, + provenance, + ); + } + + /** Shape rendered content into the channel's egress envelope (EmailProvider reads subject/text/html). */ + private toPayload(channel: ExternalChannel, content: RenderedContent, attachments?: AttachmentRef[]): Record { + if (channel === 'EMAIL') { + return { subject: content.subject, html: content.html, text: content.text, ...(attachments && attachments.length > 0 ? { attachments } : {}) }; + } + return { text: content.text ?? content.subject ?? '' }; // SMS: text only + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5159988..251f3b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -328,6 +328,9 @@ importers: packages/iios-service: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1090.0 + version: 3.1090.0 '@insignia/iios-adapter-sdk': specifier: workspace:* version: link:../iios-adapter-sdk @@ -364,6 +367,9 @@ importers: dotenv: specifier: ^16.4.7 version: 16.6.1 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 ioredis: specifier: ^5.11.1 version: 5.11.1 @@ -373,6 +379,9 @@ importers: jwks-rsa: specifier: ^4.1.0 version: 4.1.0 + nodemailer: + specifier: ^9.0.3 + version: 9.0.3 prisma: specifier: ^6.2.1 version: 6.19.3(typescript@5.9.3) @@ -407,6 +416,9 @@ importers: '@types/node': specifier: ^26.0.1 version: 26.0.1 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/web-push': specifier: ^3.6.4 version: 3.6.4 @@ -478,6 +490,78 @@ packages: resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==} engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + '@aws-sdk/checksums@3.1000.18': + resolution: {integrity: sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1090.0': + resolution: {integrity: sha512-R6GX9cd1jljwzZ8xFmgAI/hHCuX1MobIKBdsymv7WL9SENvO9Vgz9KOR6avTnu0Ao+w1LmxnTe+jqmZXEn7Q/Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.975.3': + resolution: {integrity: sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.59': + resolution: {integrity: sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.61': + resolution: {integrity: sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.4': + resolution: {integrity: sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.66': + resolution: {integrity: sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.70': + resolution: {integrity: sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.59': + resolution: {integrity: sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.3': + resolution: {integrity: sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.65': + resolution: {integrity: sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.64': + resolution: {integrity: sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.33': + resolution: {integrity: sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.41': + resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1088.0': + resolution: {integrity: sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.36': + resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -1441,6 +1525,30 @@ packages: cpu: [x64] os: [win32] + '@smithy/core@3.29.5': + resolution: {integrity: sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.10': + resolution: {integrity: sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.7': + resolution: {integrity: sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.7': + resolution: {integrity: sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.6': + resolution: {integrity: sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -1517,6 +1625,9 @@ packages: '@types/node@26.0.1': resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1754,6 +1865,9 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -2224,6 +2338,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2537,6 +2656,10 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + notepack.io@3.0.1: resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} @@ -3052,6 +3175,11 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + uid2@1.0.0: resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} engines: {node: '>= 4.0.0'} @@ -3243,6 +3371,9 @@ packages: engines: {node: '>=8'} hasBin: true + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -3333,6 +3464,171 @@ snapshots: transitivePeerDependencies: - chokidar + '@aws-sdk/checksums@3.1000.18': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1090.0': + dependencies: + '@aws-sdk/checksums': 3.1000.18 + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-node': 3.972.70 + '@aws-sdk/middleware-sdk-s3': 3.972.64 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.975.3': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.36 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.5 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.61': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.4': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-login': 3.972.66 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.66': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.70': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-ini': 3.973.4 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.3': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/token-providers': 3.1088.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.65': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.64': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.33': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.41': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1088.0': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.36': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4076,6 +4372,39 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@smithy/core@3.29.5': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.10': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.6': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.8)': @@ -4179,6 +4508,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 26.0.1 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -4459,6 +4792,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -5013,6 +5348,15 @@ snapshots: graceful-fs@4.2.11: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5292,6 +5636,8 @@ snapshots: node-releases@2.0.50: {} + nodemailer@9.0.3: {} + notepack.io@3.0.1: {} nypm@0.6.8: @@ -5821,6 +6167,9 @@ snapshots: ufo@1.6.4: {} + uglify-js@3.19.3: + optional: true + uid2@1.0.0: {} uid@2.0.2: @@ -6008,6 +6357,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0