Compare commits

19 Commits

Author SHA1 Message Date
maaz519 00d22be714 Merge pull request 'feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads' (#5) from feat/s3-storage into dev
Reviewed-on: #5
2026-07-20 09:01:56 +00:00
maaz519 b4104b9769 feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads
- media upload policy now allows text/html, text/markdown, text/x-markdown,
  text/csv (in addition to images/av, pdf, txt, zip, office docs)
- blob endpoint adds X-Content-Type-Options: nosniff, and forces
  Content-Disposition: attachment for script-capable types (html, xhtml, svg,
  xml) so an uploaded file can't render/execute inline from the IIOS origin
  (stored-XSS). Images/video/audio/pdf still serve inline for preview.
- dev-opa test covering the allowed types + unknown/oversize denials

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:30:25 +05:30
maaz519 32aa04503d Merge pull request 'Feat/s3 storage' (#4) from feat/s3-storage into dev
Reviewed-on: #4
2026-07-18 12:28:22 +00:00
maaz519 c8c2d0811b Merge remote-tracking branch 'origin/dev' into feat/s3-storage 2026-07-18 17:57:21 +05:30
maaz519 ce33834d56 feat(threads): governed group settings — rename, list members, remove participant
- MessageService.renameThread / removeParticipant / listParticipants,
  each fail-closed via OPA (kernel stays generic — no dm/group branching)
- dev OPA: iios.thread.update + iios.thread.participant.remove rules
  (group requires ADMIN; ungoverned threads unchanged)
- REST: PATCH /v1/threads/:id, GET + DELETE /v1/threads/:id/participants
- tests: admin renames/removes, member is denied, member list carries roles
  (message.spec + dev-opa.port.spec)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:34:28 +05:30
maaz519 b164ef945c feat(mail): in-app attachments on internal mail + surface media parts in thread reads
- MailInternalDto accepts attachments[]; deposit() stores each as a media
  part (image/video→MEDIA_REF, audio→VOICE_REF, else FILE_REF)
- ingest now persists part sizeBytes; contract + DTO carry it
- threads.getMessages returns mimeType + sizeBytes so mail readers can
  render inline images / file chips
- test: internal mail stores an image attachment as a MEDIA_REF part

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 17:24:49 +05:30
maaz519 18498ee9fa Merge pull request 'feat(mail): tag mail threads source=crm-mail (list separately from chat)' (#3) from feat/s3-storage into dev
Reviewed-on: #3
2026-07-18 11:12:35 +00:00
maaz519 40c98522dc feat(mail): tag mail threads source=crm-mail (list separately from chat)
ingest() puts metadata on the interaction, not the thread, and listThreads filters
THREAD metadata — so MailService now tags the thread directly after deposit
(source=crm-mail). Lets the CRM list mail threads apart from Messenger chat
(source=crm-messenger). 7 tests. NOTE: requires an IIOS redeploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:30:28 +05:30
maaz519 50f9b3213d Merge pull request 'Feat/s3 storage' (#1) from feat/s3-storage into dev
Reviewed-on: #1
2026-07-18 09:06:39 +00:00
maaz519 0c6650f3c6 feat(media): S3/MinIO storage adapter (StoragePort)
Drop-in S3-compatible backend for object storage (media + email attachments) —
the swap the StoragePort seam was designed for. MinIO/self-hosted/R2/Supabase all
work via endpoint + path-style.

- S3Storage implements StoragePort (put/get/remove); sha256 computed locally on put
  (matches LocalDiskStorage); a missing object reads back as null, not an error.
- Env-driven binding in MediaModule: IIOS_S3_BUCKET + keys set → S3Storage, else
  LocalDiskStorage. forcePathStyle defaults true (MinIO); endpoint omitted → AWS.
- S3 client is injectable so tests run with no live server.

6 unit tests (config parse, put size/sha, get round-trip, NoSuchKey→null, remove).
Full suite 300/300, boundary + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:26:53 +05:30
maaz519 d28c873d40 feat(mail): email attachments over SMTP
Email can now carry attachments (e.g. an invoice PDF). The kernel already stored
attachments as message parts + the media StoragePort holds the bytes; the gap was
the email envelope.

- EMAIL payload carries attachment REFS ({filename, contentRef, mimeType}), not
  bytes — the ledger + T8 PII redaction stay small; bytes are fetched at send time.
- SmtpProvider takes an AttachmentResolver; resolves each ref via storage and attaches
  (nodemailer). FAILS CLOSED if a declared attachment can't be resolved (or no resolver
  is wired) — never send a receipt/invoice missing its file; a FAILED command retries.
- CapabilityProviderRegistry injects the resolver from STORAGE_PORT (@Optional);
  MediaModule exports STORAGE_PORT, CapabilityModule imports MediaModule. No cycle.
- TemplatedSender + MailService + the /v1/mail/send DTO pass attachments through.

Verified: 6 new unit tests (attach, fail-closed x2, plain-unaffected, resolver,
pass-through) + a REAL Ethereal SMTP send WITH a PDF attachment (SENT). Full suite
294/294, boundary + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:14:20 +05:30
maaz519 9b075f46f9 feat(mail): inbox mirror + INTERNAL (app-to-app) delivery
MailService renders a template then deposits it as an EMAIL interaction on a
per-email thread (thread model: one thread per email), reusing IngestService.
Because ingest adds no participants and a thread is only visible to its
participants, it ensureParticipant()s BOTH sender and recipient — so the mirror
is actually visible.

- postInternal: app-to-app mail, no SMTP → recipient's in-app inbox.
- sendExternalWithMirror: SMTP send (TemplatedSender) + mirror an interaction
  ONLY for a registered recipient (pre-registration sends are email-only — no
  inbox exists yet). Idempotent across both the send and the mirror.
- Writes Interactions, NEVER InboxItems (the projector owns those — KG-15).
- POST /v1/mail/internal, POST /v1/mail/send. MailModule in AppModule.

Verified over HTTP: internal mail → recipient SEES the thread in their inbox;
external send → command + mirror; no-source/no-auth 400. 7 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:54:32 +05:30
maaz519 bba5fae061 docs(mail): inbox mirror + INTERNAL delivery plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:49:19 +05:30
maaz519 74cca2d534 feat(smtp): SMTP egress provider (nodemailer) for the EMAIL channel
Makes external email actually leave the building (was sandbox-only). SmtpProvider
implements CapabilityProvider; env-activated (accounts@ primary, ceo@ fallback);
transporter injected for tests.

- send() maps target+payload -> {from,to,subject,html,text,inReplyTo,references};
  providerRef = nodemailer's real Message-ID (so replies thread via In-Reply-To).
- Fallback ONLY on pre-acceptance failures (connect/auth/timeout) — a post-acceptance
  error is terminal, so a message the server already took can't be double-delivered.
- Never throws — transport failure -> FAILED, per the adapter doctrine.
- Registry precedence via registration order: SMTP > HTTP relay > sandbox for EMAIL.

Verified: 13 unit tests (config/envelope/fallback/precedence) + a REAL SMTP round-trip
against nodemailer Ethereal (SENT, genuine Message-ID). Full suite 281/281, boundary+build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:31:04 +05:30
maaz519 7d7c75915a docs(smtp): SMTP provider plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:25:37 +05:30
maaz519 65023ce404 feat(templates): T8 PII redaction of outbound commands (fast-follow)
An email/SMS command holds PII: the recipient address (target) and the rendered
body (payload). RetentionService now snapshots outbound commands and, once aged,
redacts target->'[redacted]' and payload->{redacted:true} in place while KEEPING
the template provenance (key/version/locale/hash) — so 'which template version did
we send?' stays answerable after the PII is gone.

- ensureOutboundSnapshots: one snapshot per command, dataClass 'outbound',
  archiveAfter==deleteAfter (PII goes straight to redact, no archive phase).
  Nullable command scope uses an 'unscoped' tag so the global sweep still reaches it.
- applySweep redact branch switches on targetType; compliance holds honored; audit
  'retention.redacted' resourceType 'outbound_command'.

Closes lever #2 of the PII-minimization plan. 3 new + 6 regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:56:22 +05:30
maaz519 b44f795ba4 feat(templates): T6 controller + DTOs + module wiring
POST /v1/templates/send (render→queue) and /preview (render only). OPA-gated in
the sender: iios.template.send, and iios.template.send.inline for ad-hoc HTML
(arbitrary markup to a customer is riskier than a reviewed stored template).
Scope resolved from the caller's principal. TemplateModule registered in AppModule.

Verified over HTTP against a local boot: preview + send of the seeded
payment.receipt (SENT via sandbox, provenance persisted), XSS name escaped,
idempotent replay → 1 row, unknown key 404, bad channel/no-source/no-auth 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:39:04 +05:30
maaz519 37407cb0af feat(templates): T5 boot seeder + default seeds
TemplateSeeder (OnModuleInit) seeds repo file defaults as global (scopeId NULL)
templates if absent. Idempotent per (key,channel,locale,version): re-boot inserts
nothing; a bumped version adds a new row and keeps the old for audit. Seeds:
welcome (email), payment.receipt (email+sms), onboarding.reminder (email) —
placeholder copy for marketing to replace. 3 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:33:12 +05:30
maaz519 10f3545a25 feat(templates): T4 sendTemplated() + outbound provenance
- IiosOutboundCommand gains templateKey/version/locale/renderedHash (migration).
- OutboundService.send accepts an optional opaque `provenance` and writes it into
  the command on BOTH the PENDING and RATE_LIMITED create paths — the only way to
  stamp provenance by construction, since send() creates the row itself (review
  finding). OutboundService still neither renders nor resolves templates.
- TemplatedSender.sendTemplated: render -> OutboundService.send -> provenance, one
  entry point. EMAIL payload = {subject,html,text}; SMS = {text}. Idempotent per key.

Tests: 4 sender + 16 adapters regression green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:30:19 +05:30
43 changed files with 2323 additions and 20 deletions
+67
View File
@@ -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.
+113
View File
@@ -0,0 +1,113 @@
# Inbox Mirror + INTERNAL Delivery — Implementation Plan
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** every message the app
sends appears in the customer's in-app inbox; and users can send app-to-app "mail" with no SMTP.
## Goal
Two capabilities on one mechanism:
1. **Mirror** — when an external EMAIL is sent, also record it as an in-app interaction so the
customer sees a copy in their CRM inbox. Vivek: *"जो भी communication…उसकी एक copy inbox में चाहिए ही चाहिए."*
2. **INTERNAL delivery** — a user sends a mail-style message (subject + body) to another user with
**no SMTP**; it lands only in the recipient's in-app inbox. Vivek: *"app-to-app…without smtp."*
Both reduce to the same primitive: **render a template → create an `Interaction(kind=EMAIL)` with
subject + HTML + TEXT parts on a thread.** External additionally does the SMTP send (already built).
## Architectural guardrail (carried from the earlier inbox work)
This is the **mail-style inbox (a projection over `Interaction`s)** — NOT the `InboxItem` work-surface.
- An email/message becomes an `Interaction(kind=EMAIL)` on a thread. The inbox view lists interactions.
- An `InboxItem` is created ONLY when the projector decides action is needed (NEEDS_REPLY/MENTION) —
that's the existing projector, unchanged. **We do not write InboxItems here.** Mixing them is the
KG-15 "inbox fatigue" failure.
## What already exists (reuse, do NOT rebuild)
- `IngestService.ingest(req, idempotencyKey)` — the generic create-an-interaction entry: resolves
source handle → actor → channel → thread, writes `Interaction` (kind from `req.kind`) + parts +
outbox event, idempotent per (scope, idempotencyKey). Inbound email already uses it to make
`EMAIL` interactions with HTML/TEXT parts — **the exact model for the outbound mirror.**
- `TemplateService.render()` (exported) → `{subject, html, text}`.
- `TemplatedSender.sendTemplated()` → SMTP egress (built).
- `IiosMessagePartKind` has `HTML` + `TEXT`; `IiosInteractionKind` has `EMAIL`.
## Design (locked)
- **New `MailService`** (new `src/mail/` module) orchestrates `TemplateService` + `IngestService` +
`TemplatedSender` + `ActorResolver`. Templates/outbound stay unaware of each other.
- `postInternal(...)` — render → `ingest()` an `EMAIL` interaction on a per-email thread. No SMTP.
- `sendExternalWithMirror(...)` — render → `TemplatedSender.sendTemplated()` (SMTP) → **and** mirror
via `ingest()` **iff the recipient is a registered user** (timing rule below).
- **Visibility (resolved review finding):** `ingest()` creates the interaction + thread but adds NO
participants, and `listThreads` shows only threads where the caller is a participant. So after each
ingest the MailService `ensureParticipant`s **both** the sender's actor and the recipient's actor
(`ActorResolver.resolveActor``ensureParticipant`). Without this the mirror is invisible.
- **`ingest()` returns `threadId`** — used directly to add the two participants.
- **Rendered content → parts:** part 0 `HTML` (bodyHtml), part 1 `TEXT` (bodyText); `subject` → the
thread subject (email threads share a subject). Attachments are the separate attachments plan.
- **Idempotency:** the ingest idempotencyKey = the send's key (e.g. `mirror:<stripe_session>`), so a
retried send never doubles the inbox copy.
- **Reply/threading:** `parentInteractionId` for in-thread replies (already modeled); a mirrored
email's `inReplyTo` maps to the parent interaction.
## The timing rule (locked, from the meeting)
**Mirror only AFTER the recipient is registered.** The welcome/receipt go out *before* registration —
there is no in-app inbox to mirror into yet. So `sendExternalWithMirror` mirrors only when the target
resolves to a registered actor; pre-registration sends are email-only. Vivek: *"just time app pe
register kar liya, uske baad se jitna communication…uske inbox mein chahiye."*
## Thread model (DECIDED: one thread per email)
**Each send is its own thread / inbox entry; a reply threads onto it.** Matches email semantics and
pairs with the reply (`parentInteractionId`) feature. Implementation: the ingest `externalThreadId`
is **derived from the send's idempotency key**, so a retried send reuses the same thread (no dupe)
while distinct emails get distinct threads. A reply posts onto the parent's thread.
## Files
```
src/mail/mail.service.ts # new — postInternal, sendExternalWithMirror
src/mail/mail.service.spec.ts # new — DB-backed
src/mail/mail.module.ts # new — imports TemplateModule + AdaptersModule + interactions
src/mail/mail.controller.ts # new? — OR extend template.controller with a `deliverInternal` route
```
(Whether INTERNAL gets its own HTTP route or rides the template controller is a small call made at build time.)
## Task-by-task (TDD) — pending the thread-model decision
**T1 — `renderToParts()` helper**: `{subject,html,text}``IngestInteractionRequest.parts` +
thread subject. Test: HTML+TEXT parts produced; empty parts omitted.
**T2 — `postInternal()`**: render an INTERNAL template → `ingest()` an `EMAIL` interaction on the
thread between sender + recipient (thread model per the decision). Test: interaction created with
kind EMAIL + parts; idempotent per key; lands on the recipient's thread.
**T3 — `sendExternalWithMirror()`**: render → `sendTemplated` (SMTP/sandbox) → mirror `ingest()`
**only if** the recipient resolves to a registered actor. Test: registered → one outbound command +
one mirror interaction; unregistered → outbound only, no mirror; idempotent (replay → no dupes).
**T4 — controller/module wiring + HTTP verify** (route for INTERNAL send; mirror invoked from the
external send path). Boot + drive over HTTP against the sandbox.
**T5 — gate**: full suite + boundary + build; manual: send external → confirm a mirror interaction
appears on the recipient's thread.
## Out of scope (follow-ons)
- **Frontend mail-inbox view** — surfacing `EMAIL` interactions as a mail-style inbox in the CRM
(the current CRM inbox is the InboxItem work-surface; the mail view is separate UI).
- **Attachments** (separate plan). **Stripe webhook** (be-crm) — the trigger.
## Risks
- **Don't write InboxItems here** (KG-15). Interactions only; the projector owns InboxItems.
- **Idempotency must cover BOTH** the SMTP send and the mirror ingest, or a retried webhook doubles
the inbox copy. Same key threaded through both.
- **Unregistered recipients:** resolving "is this a registered user?" must be cheap and correct, or a
pre-registration send could either error or wrongly mirror into a non-existent inbox.
- **Review finding — recipient participation:** `ingest()` resolves and attaches the *source* actor.
For the interaction to appear in the *recipient's* inbox, the **recipient must be a thread
participant.** T2/T3 must ensure this — either by making the thread's participant set include the
recipient at create time, or an explicit `ensureParticipant` after ingest. A mirror the recipient
isn't a participant of is invisible — silent failure. Cover it with an assertion in the tests
("recipient can list the thread / the interaction shows in their inbox query").
+139
View File
@@ -0,0 +1,139 @@
# SMTP Provider — Implementation Plan
**Owner:** Maaz · **Repo:** `iios` (`packages/iios-service`) · **Purpose:** make external email *actually leave the building* (welcome / receipt), the critical path for Friday.
## Goal
Add an `SmtpProvider` so the `EMAIL` channel delivers via real SMTP (`accounts@lynkeduppro.com`,
fallback `ceo@lynkeduppro.com`) instead of the sandbox. The template module already renders and
queues to the `EMAIL` channel; this is the one piece between "queued (SENT via sandbox)" and "the
customer receives it." From the meeting: *"जो पहला जा रहा है, वो SMTP से जा रहा है, क्योंकि हमें तुरंत चाहिए."*
## What already exists (do NOT rebuild)
- `CapabilityProvider { name, channelTypes, capabilities, send(req) }` — the seam.
- `CapabilityProviderRegistry` binds a provider per channel: **sandbox by default**; `EmailProvider`
(HTTP) when `IIOS_PROVIDER_URL_EMAIL` is set. Unknown channels fail closed.
- `OutboundService.send``CapabilityBroker` (policy + obligations) → the bound provider. Idempotency,
rate limits, ledger, provenance all upstream — untouched.
- `req.payload` for EMAIL is `{ subject, text, html, inReplyTo }` (from `TemplatedSender`).
## Design decisions (locked)
| # | Decision | Why |
|---|---|---|
| 1 | New `SmtpProvider implements CapabilityProvider`, `channelTypes=['EMAIL']`, via **nodemailer** | The established provider pattern; nodemailer is the standard SMTP client |
| 2 | **Env-driven activation**, like `IIOS_PROVIDER_URL_EMAIL` | Off by default (sandbox); flip on by setting SMTP env — no code change to enable |
| 3 | **Registry precedence for EMAIL: SMTP > HTTP > sandbox** | SMTP is the intended prod path; HTTP relay stays available; sandbox is the safe default |
| 4 | **Transporter is injected** (constructor takes a `Transporter` or a factory) | SMTP is untestable against a live server in CI; inject a stub/`jsonTransport` to assert the envelope |
| 5 | **Optional fallback sender** (`accounts@` primary → `ceo@` on failure) | The meeting's fallback: if the primary mailbox send fails, retry once via the fallback identity |
| 6 | **Never throw** — a transport error returns `{ outcome: 'FAILED', errorCode }` | Adapter doctrine; the command is marked FAILED, the caller isn't broken |
## Config (env)
```
IIOS_SMTP_HOST=smtp.<mail-host> # e.g. smtp.gmail.com (Google Workspace)
IIOS_SMTP_PORT=587
IIOS_SMTP_SECURE=false # true for 465, false for 587/STARTTLS
IIOS_SMTP_USER=accounts@lynkeduppro.com
IIOS_SMTP_PASS=<app password> # Workspace App Password, NOT the account password
IIOS_SMTP_FROM="LynkedUp Pro <accounts@lynkeduppro.com>" # defaults to USER
# optional fallback identity used only if the primary send FAILS
IIOS_SMTP_FALLBACK_USER=ceo@lynkeduppro.com
IIOS_SMTP_FALLBACK_PASS=<app password>
IIOS_SMTP_FALLBACK_FROM="Justin Johnson <ceo@lynkeduppro.com>"
```
Activation rule: `SmtpProvider` is bound for `EMAIL` iff `IIOS_SMTP_HOST` + `IIOS_SMTP_USER` +
`IIOS_SMTP_PASS` are all set. Fallback transporter built only if the `_FALLBACK_*` trio is set.
## Files
```
src/capability/smtp.provider.ts # new — the provider
src/capability/smtp.provider.spec.ts # new — injected-transport tests
src/capability/capability.registry.ts # modify — bind SMTP for EMAIL when configured (precedence)
package.json # add nodemailer + @types/nodemailer
```
## Contract
```ts
interface SmtpIdentity { host: string; port: number; secure: boolean; user: string; pass: string; from: string }
class SmtpProvider implements CapabilityProvider {
readonly name = 'smtp';
readonly channelTypes = ['EMAIL'];
readonly capabilities = { canSend: true };
// `makeTransport` is injectable so tests pass a stub / nodemailer jsonTransport.
constructor(primary: SmtpIdentity, fallback?: SmtpIdentity, makeTransport?: (id: SmtpIdentity) => Transporter) {}
async send(req: CapabilityRequest): Promise<ProviderResult>;
}
```
`send()` builds the mail from `req.target` (recipient) + `req.payload`:
```
{ from, to: req.target, subject, text, html,
inReplyTo?, references?, // threading, from payload.inReplyTo
messageId } // generated; returned as providerRef so replies can thread
```
Primary transporter sends; on throw, if a fallback identity exists, retry once via it; still failing
`FAILED`. Success → `{ outcome: 'SENT', providerRef: messageId, latencyMs }`.
**Review findings folded in:**
- **`providerRef` = nodemailer's returned `info.messageId`**, not a hand-generated id — nodemailer
stamps the real `Message-ID` it sent, which is what a reply's `In-Reply-To` will actually match.
- **Fallback only on PRE-acceptance failures** (connection refused, auth failure, timeout) — NOT on
an error raised after the SMTP server already accepted the message. Retrying a post-acceptance
failure via the fallback identity would **double-deliver**. `send()` inspects the error (nodemailer
`err.responseCode` / code) and falls back only when the server never accepted.
- **Registry precedence is registration ORDER:** `register()` does `byChannel.set(ch, provider)`, so
the LAST registration for `EMAIL` wins. Bind sandbox first (all channels), then HTTP `EmailProvider`
if its URL is set, then `SmtpProvider` **last** if SMTP env is set → SMTP > HTTP > sandbox falls out.
## Task-by-task (TDD)
Each: failing test → red → implement → green → commit. Tests inject a stub transporter (no network).
**T1 — provider skeleton + config parse**
- `smtpIdentityFromEnv()` reads the env trio; returns null if incomplete.
- Test: full env → identity; missing pass → null; fallback trio → fallback identity.
**T2 — `send()` builds the correct envelope**
- Inject a recording stub transporter. Test: `from`/`to`/`subject`/`html`/`text` map from target+payload;
`inReplyTo` → header set when present; `messageId` generated and returned as `providerRef`; outcome `SENT`.
**T3 — failure handling + fallback**
- Stub throws on primary. Test: with a fallback identity → retries via fallback, `SENT` via fallback
transporter; without fallback → `FAILED` with `errorCode`, **never throws**.
**T4 — registry precedence**
- `capability.registry.spec` (or extend): with SMTP env set, `forChannel('EMAIL')` returns the SMTP
provider (not sandbox/HTTP); with only `IIOS_PROVIDER_URL_EMAIL` → HTTP; with neither → sandbox.
- The registry reads env in its constructor, so each case sets env, constructs a fresh
`CapabilityProviderRegistry`, asserts, then restores env (mirror the env save/restore other specs use).
**T5 — gate + manual real send**
- `vitest run` (all), `boundary`, `build` green.
- **Manual (ops):** point env at a real mailbox (or nodemailer **Ethereal** test SMTP for a no-mailbox
end-to-end), boot, `POST /v1/templates/send` the `welcome` seed to your own address, confirm receipt
and that From = `accounts@lynkeduppro.com`.
## Error handling
- Incomplete SMTP env → provider not bound → EMAIL falls back to sandbox (no accidental silent prod send).
- Transport failure → `FAILED` on the command (+ delivery attempt), never thrown.
- Fallback used → `providerRef` notes the fallback identity for audit.
## Risks / prerequisites
- 🔴 **Rotate `Qwerty@a2` BEFORE enabling.** This is the switch that turns queued sends into real
emails to real addresses — a forgeable IIOS token now reaches customer inboxes under your brand.
- **Workspace App Password, not the account password** (2FA accounts reject the raw password over SMTP).
- **Deliverability:** SPF + DKIM + DMARC on `lynkeduppro.com` or mail lands in spam. Ops task, before real customers.
- **Sending limits:** Google Workspace SMTP ≈ 2000/day. Fine — instant welcome/receipt is low volume; the
drip goes via Mailchimp, not SMTP.
- **Test safety:** never point CI/test env at a real mailbox; tests use an injected stub, the manual step
uses Ethereal or a throwaway inbox.
## Out of scope (separate plans)
Attachments over SMTP (extends `EmailPayload` + this provider); the inbox mirror / INTERNAL delivery
(no SMTP dependency).
+1
View File
@@ -27,6 +27,7 @@ export interface IngestInteractionRequest {
bodyText?: string;
contentRef?: string;
mimeType?: string;
sizeBytes?: number;
}>;
occurredAt: string;
providerEventId?: string;
+9
View File
@@ -59,3 +59,12 @@ IIOS_ATTESTATION_AUDIENCE=iios-core
# When '1', every guarded request MUST carry a valid X-Context-Attestation (else 403).
# Leave OFF until callers (AppShell/be-crm) forward attestations. Verified-if-present regardless.
IIOS_REQUIRE_ATTESTATION=0
# ─── Object storage (media + email attachments) ───────────────────
# Unset → local disk (MEDIA_DIR). Set these → S3-compatible (AWS S3 / MinIO / R2 / Supabase).
# IIOS_S3_ENDPOINT=https://minio.your-server:9000 # omit for AWS S3
# IIOS_S3_BUCKET=iios-media
# IIOS_S3_ACCESS_KEY=...
# IIOS_S3_SECRET_KEY=...
# IIOS_S3_REGION=us-east-1 # any value for MinIO
# IIOS_S3_FORCE_PATH_STYLE=true # true for MinIO/self-hosted
+3
View File
@@ -12,6 +12,7 @@
"prisma:studio": "prisma studio"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1090.0",
"@insignia/iios-adapter-sdk": "workspace:*",
"@insignia/iios-contracts": "workspace:*",
"@nestjs/common": "^11.1.27",
@@ -28,6 +29,7 @@
"ioredis": "^5.11.1",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^4.1.0",
"nodemailer": "^9.0.3",
"prisma": "^6.2.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
@@ -41,6 +43,7 @@
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^26.0.1",
"@types/nodemailer": "^8.0.1",
"@types/web-push": "^3.6.4",
"socket.io-client": "^4.8.3",
"typescript": "^5.7.3"
@@ -0,0 +1,9 @@
-- Template provenance on the outbound command: which template (key/version/locale) produced this
-- send, plus a hash of the rendered content. The rendered content itself already lives in `payload`;
-- these columns answer "which template version produced this send?" for replay/audit. All nullable —
-- non-templated sends leave them null.
ALTER TABLE "IiosOutboundCommand"
ADD COLUMN "templateKey" TEXT,
ADD COLUMN "templateVersion" INTEGER,
ADD COLUMN "templateLocale" TEXT,
ADD COLUMN "renderedHash" TEXT;
@@ -868,6 +868,11 @@ model IiosOutboundCommand {
idempotencyKey String @unique
providerRef String?
consentReceiptRef String? // CMP consent receipt this send went out under (P9)
// Template provenance (which source produced this send) — null for non-templated sends.
templateKey String?
templateVersion Int?
templateLocale String?
renderedHash String? // sha256 of the rendered content, for replay/audit
createdAt DateTime @default(now())
attempts IiosDeliveryAttempt[]
@@ -34,8 +34,18 @@ export class OutboundService {
idempotencyKey?: string,
scopeId?: string,
purpose?: string,
/** Opaque template provenance recorded on the command. OutboundService neither renders nor
* resolves templates — it only persists the four strings it is handed (guaranteed by the
* templated sender, never by caller convention). */
provenance?: { templateKey?: string | null; templateVersion?: number | null; templateLocale?: string | null; renderedHash?: string | null },
) {
const key = idempotencyKey ?? randomUUID();
const prov = {
templateKey: provenance?.templateKey ?? null,
templateVersion: provenance?.templateVersion ?? null,
templateLocale: provenance?.templateLocale ?? null,
renderedHash: provenance?.renderedHash ?? null,
};
// The actual send. The inner findUnique stays as a backstop so a FAILED-then-retried
// command returns the existing row instead of colliding on idempotencyKey @unique.
@@ -46,14 +56,14 @@ export class OutboundService {
// Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key },
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey: key, ...prov },
});
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
return cmd;
}
const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key },
data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey: key, ...prov },
});
let result;
+4
View File
@@ -10,6 +10,8 @@ import { OutboxModule } from './outbox/outbox.module';
import { ThreadsModule } from './threads/threads.module';
import { MessageModule } from './messaging/message.module';
import { InboxModule } from './inbox/inbox.module';
import { TemplateModule } from './templates/template.module';
import { MailModule } from './mail/mail.module';
import { MediaModule } from './media/media.module';
import { NotificationModule } from './notifications/notification.module';
import { SupportModule } from './support/support.module';
@@ -37,6 +39,8 @@ import { DevController } from './dev/dev.controller';
ThreadsModule,
MessageModule,
InboxModule,
TemplateModule,
MailModule,
MediaModule,
NotificationModule,
SupportModule,
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MediaModule } from '../media/media.module';
import { CapabilityProviderRegistry } from './capability.registry';
import { CapabilityBroker } from './capability.broker';
@@ -8,6 +9,7 @@ import { CapabilityBroker } from './capability.broker';
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
*/
@Module({
imports: [MediaModule],
providers: [CapabilityProviderRegistry, CapabilityBroker],
exports: [CapabilityBroker, CapabilityProviderRegistry],
})
@@ -0,0 +1,34 @@
import { describe, it, expect, afterEach } from 'vitest';
import { CapabilityProviderRegistry } from './capability.registry';
// The registry reads env in its constructor, so each case sets env → constructs a fresh registry →
// asserts → restores env.
const SMTP_KEYS = ['IIOS_SMTP_HOST', 'IIOS_SMTP_USER', 'IIOS_SMTP_PASS', 'IIOS_PROVIDER_URL_EMAIL'];
const saved: Record<string, string | undefined> = {};
function set(env: Record<string, string | undefined>) {
for (const k of SMTP_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
for (const [k, v] of Object.entries(env)) if (v != null) process.env[k] = v;
}
afterEach(() => { for (const k of SMTP_KEYS) { if (saved[k] == null) delete process.env[k]; else process.env[k] = saved[k]; } });
describe('CapabilityProviderRegistry — EMAIL precedence (SMTP > HTTP > sandbox)', () => {
it('binds SMTP for EMAIL when the SMTP env trio is set', () => {
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' });
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
});
it('binds the HTTP EmailProvider when only IIOS_PROVIDER_URL_EMAIL is set', () => {
set({ IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('email-http');
});
it('SMTP wins over the HTTP relay when both are set', () => {
set({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p', IIOS_PROVIDER_URL_EMAIL: 'https://relay.test/send' });
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('smtp');
});
it('falls back to the sandbox when neither is configured', () => {
set({});
expect(new CapabilityProviderRegistry().forChannel('EMAIL').name).toBe('sandbox');
});
});
@@ -1,22 +1,27 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common';
import type { CapabilityProvider } from '@insignia/iios-contracts';
import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider';
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'WHATSAPP', 'PORTAL'];
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
/**
* Maps a channelType to the provider that executes egress for it. Sandbox by
* default; if `IIOS_PROVIDER_URL_<CHANNELTYPE>` is set, a real HttpProvider
* overrides the sandbox for that channel (the "flip the binding" swap). Unknown
* channels fail closed — no silent egress path.
*
* Precedence is registration ORDER (register() does Map.set → last wins). For EMAIL:
* sandbox → HTTP EmailProvider (if URL set) → SMTP (if SMTP env set), so SMTP > HTTP > sandbox.
*/
@Injectable()
export class CapabilityProviderRegistry {
private readonly byChannel = new Map<string, CapabilityProvider>();
constructor() {
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) {
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
for (const ch of DEFAULT_CHANNELS) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
@@ -24,6 +29,13 @@ export class CapabilityProviderRegistry {
// EMAIL gets an email-shaped envelope provider; other channels use the generic HTTP one.
this.register(ch === 'EMAIL' ? new EmailProvider(url) : new HttpProvider(ch, url));
}
// Real SMTP for EMAIL wins over the HTTP relay when configured (registered last). Attachments
// resolve through the media StoragePort when one is bound (else attachments FAIL closed).
const smtp = smtpIdentityFromEnv();
if (smtp) {
const resolver = this.storage ? storageResolver(this.storage) : undefined;
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
}
}
register(provider: CapabilityProvider): void {
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import { SmtpProvider, smtpIdentityFromEnv, smtpFallbackFromEnv, storageResolver, type MailTransport, type SmtpIdentity } from './smtp.provider';
import type { CapabilityRequest } from '@insignia/iios-contracts';
const ID: SmtpIdentity = { host: 'smtp.test', port: 587, secure: false, user: 'accounts@lynkeduppro.com', pass: 'p', from: 'accounts@lynkeduppro.com' };
const FB: SmtpIdentity = { ...ID, user: 'ceo@lynkeduppro.com', from: 'Justin <ceo@lynkeduppro.com>' };
const req = (payload: Record<string, unknown>): CapabilityRequest => ({
capability: 'channel.send', channelType: 'EMAIL', target: 'dana@acme.com', payload, idempotencyKey: 'k1',
});
/** A recording transport; optionally throws a given error on send. */
function stub(opts: { throwErr?: unknown; messageId?: string } = {}) {
const calls: Array<{ id: SmtpIdentity; mail: Parameters<MailTransport['sendMail']>[0] }> = [];
const make = (id: SmtpIdentity): MailTransport => ({
async sendMail(mail) {
calls.push({ id, mail });
if (opts.throwErr) throw opts.throwErr;
return { messageId: opts.messageId ?? '<generated@smtp.test>' };
},
});
return { make, calls };
}
describe('smtpIdentityFromEnv', () => {
const base = { IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'accounts@x', IIOS_SMTP_PASS: 'p' };
it('builds the identity from a complete env trio (defaults port 587, secure false)', () => {
const id = smtpIdentityFromEnv({ ...base } as NodeJS.ProcessEnv);
expect(id).toMatchObject({ host: 'smtp.test', port: 587, secure: false, user: 'accounts@x', from: 'accounts@x' });
});
it('returns null when the trio is incomplete', () => {
expect(smtpIdentityFromEnv({ IIOS_SMTP_HOST: 'smtp.test', IIOS_SMTP_USER: 'a@x' } as NodeJS.ProcessEnv)).toBeNull();
});
it('reads the fallback identity, reusing the primary host', () => {
const fb = smtpFallbackFromEnv({ ...base, IIOS_SMTP_FALLBACK_USER: 'ceo@x', IIOS_SMTP_FALLBACK_PASS: 'q', IIOS_SMTP_FALLBACK_FROM: 'CEO <ceo@x>' } as NodeJS.ProcessEnv);
expect(fb).toMatchObject({ host: 'smtp.test', user: 'ceo@x', from: 'CEO <ceo@x>' });
});
it('returns null fallback when not configured', () => {
expect(smtpFallbackFromEnv({ ...base } as NodeJS.ProcessEnv)).toBeNull();
});
});
describe('SmtpProvider.send — envelope', () => {
it('maps target + payload into the mail and returns the messageId as providerRef', async () => {
const t = stub({ messageId: '<abc@smtp.test>' });
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'Hi Dana', html: '<p>x</p>', text: 'x' }));
expect(res).toMatchObject({ outcome: 'SENT', providerRef: '<abc@smtp.test>' });
expect(t.calls[0].mail).toMatchObject({ from: 'accounts@lynkeduppro.com', to: 'dana@acme.com', subject: 'Hi Dana', html: '<p>x</p>', text: 'x' });
});
it('sets In-Reply-To + References headers for a reply', async () => {
const t = stub();
await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 're', inReplyTo: '<parent@smtp.test>' }));
expect(t.calls[0].mail).toMatchObject({ inReplyTo: '<parent@smtp.test>', references: '<parent@smtp.test>' });
});
});
describe('SmtpProvider.send — failure + fallback', () => {
it('retries via the fallback identity on a pre-acceptance failure (SENT via fallback)', async () => {
// primary throws ECONNREFUSED (server never accepted) → fallback used.
const primaryThrows = { make: (id: SmtpIdentity): MailTransport => ({
async sendMail(mail) {
if (id.user === ID.user) throw Object.assign(new Error('refused'), { code: 'ECONNREFUSED' });
return { messageId: '<viaFallback@smtp.test>' };
},
}) };
const res = await new SmtpProvider(ID, FB, primaryThrows.make).send(req({ subject: 'x' }));
expect(res.outcome).toBe('SENT');
expect(res.providerRef).toBe('fallback:<viaFallback@smtp.test>');
});
it('does NOT retry a post-acceptance failure (avoids double delivery) → FAILED', async () => {
// responseCode present = the server already spoke; retrying could double-send.
const t = stub({ throwErr: Object.assign(new Error('rejected after data'), { responseCode: 550 }) });
const res = await new SmtpProvider(ID, FB, t.make).send(req({ subject: 'x' }));
expect(res.outcome).toBe('FAILED');
expect(t.calls).toHaveLength(1); // primary only — no fallback attempt
});
it('with no fallback, a failure is FAILED and never throws', async () => {
const t = stub({ throwErr: Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }) });
const res = await new SmtpProvider(ID, undefined, t.make).send(req({ subject: 'x' }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('ETIMEDOUT');
});
});
describe('SmtpProvider.send — attachments', () => {
const resolver = (map: Record<string, { content: Buffer; contentType?: string; filename?: string }>) =>
async (ref: string) => map[ref] ?? null;
it('resolves attachment refs to bytes and attaches them', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({ 'obj/1': { content: Buffer.from('PDFDATA'), contentType: 'application/pdf' } }))
.send(req({ subject: 'Invoice', attachments: [{ filename: 'invoice.pdf', contentRef: 'obj/1', mimeType: 'application/pdf' }] }));
expect(res.outcome).toBe('SENT');
expect(t.calls[0].mail.attachments).toEqual([{ filename: 'invoice.pdf', content: Buffer.from('PDFDATA'), contentType: 'application/pdf' }]);
});
it('FAILS closed when a declared attachment cannot be resolved (never sends without it)', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({}))
.send(req({ subject: 'Invoice', attachments: [{ contentRef: 'missing' }] }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('ATTACHMENT_UNRESOLVED');
expect(t.calls).toHaveLength(0); // nothing sent
});
it('FAILS when attachments are requested but no resolver is wired', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make) // no resolver
.send(req({ subject: 'x', attachments: [{ contentRef: 'obj/1' }] }));
expect(res).toMatchObject({ outcome: 'FAILED', errorCode: 'NO_ATTACHMENT_RESOLVER' });
});
it('a plain send with no attachments is unaffected', async () => {
const t = stub();
const res = await new SmtpProvider(ID, undefined, t.make, resolver({})).send(req({ subject: 'x', text: 'y' }));
expect(res.outcome).toBe('SENT');
expect(t.calls[0].mail.attachments).toBeUndefined();
});
});
describe('storageResolver (media StoragePort → AttachmentResolver)', () => {
it('maps storage bytes into an attachment; missing ref → null', async () => {
const storage = { get: async (k: string) => (k === 'obj/1' ? { data: Buffer.from('X'), mime: 'application/pdf' } : null) };
const r = storageResolver(storage);
expect(await r('obj/1')).toEqual({ content: Buffer.from('X'), contentType: 'application/pdf' });
expect(await r('nope')).toBeNull();
});
});
@@ -0,0 +1,185 @@
import { randomUUID } from 'node:crypto';
import { createTransport } from 'nodemailer';
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
/** One SMTP sending identity (a mailbox + how to reach its server). */
export interface SmtpIdentity {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
from: string;
}
export interface MailAttachment {
filename: string;
content: Buffer;
contentType?: string;
}
/** Fetch an attachment's bytes by its opaque contentRef (backed by the media StoragePort). */
export type AttachmentResolver = (contentRef: string) => Promise<{ filename?: string; content: Buffer; contentType?: string } | null>;
/** Build an AttachmentResolver over the media StoragePort (contentRef = the storage object key). */
export function storageResolver(storage: { get(key: string): Promise<{ data: Buffer; mime: string } | null> }): AttachmentResolver {
return async (contentRef) => {
const o = await storage.get(contentRef);
return o ? { content: o.data, contentType: o.mime } : null;
};
}
/** The subset of a mail transport this provider needs — lets tests inject a stub (no live server). */
export interface MailTransport {
sendMail(mail: {
from: string;
to: string;
subject?: string;
text?: string;
html?: string;
inReplyTo?: string;
references?: string;
attachments?: MailAttachment[];
}): Promise<{ messageId: string; accepted?: unknown[] }>;
}
const bool = (v: string | undefined): boolean => v === 'true' || v === '1';
/** Build the primary SMTP identity from env, or null if the required trio is incomplete. */
export function smtpIdentityFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
return identityFrom(env, '');
}
/** Build the optional fallback identity (accounts@ → ceo@), or null if not configured. */
export function smtpFallbackFromEnv(env: NodeJS.ProcessEnv = process.env): SmtpIdentity | null {
const fb = identityFrom(env, 'FALLBACK_');
if (fb) return fb;
// Fallback may reuse the primary host/port and only override the mailbox identity.
const host = env.IIOS_SMTP_HOST;
const user = env.IIOS_SMTP_FALLBACK_USER;
const pass = env.IIOS_SMTP_FALLBACK_PASS;
if (!host || !user || !pass) return null;
return {
host,
port: Number(env.IIOS_SMTP_PORT ?? 587),
secure: bool(env.IIOS_SMTP_SECURE),
user,
pass,
from: env.IIOS_SMTP_FALLBACK_FROM ?? user,
};
}
function identityFrom(env: NodeJS.ProcessEnv, prefix: string): SmtpIdentity | null {
const host = env[`IIOS_SMTP_${prefix}HOST`] ?? (prefix ? undefined : env.IIOS_SMTP_HOST);
const user = env[`IIOS_SMTP_${prefix}USER`];
const pass = env[`IIOS_SMTP_${prefix}PASS`];
if (!host || !user || !pass) return null;
return {
host,
port: Number(env[`IIOS_SMTP_${prefix}PORT`] ?? env.IIOS_SMTP_PORT ?? 587),
secure: bool(env[`IIOS_SMTP_${prefix}SECURE`] ?? env.IIOS_SMTP_SECURE),
user,
pass,
from: env[`IIOS_SMTP_${prefix}FROM`] ?? user,
};
}
interface EmailPayload {
subject?: string;
text?: string;
html?: string;
inReplyTo?: string;
/** Attachment REFS (not bytes) — resolved to bytes at send time via the injected resolver. */
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
}
/**
* SMTP egress provider (nodemailer). Bound for EMAIL when the SMTP env trio is set (else the sandbox
* stays). A transport failure surfaces as FAILED, never thrown. On a PRE-acceptance failure it retries
* once via the fallback identity (accounts@ → ceo@); a post-acceptance failure is NOT retried, so a
* message the server already accepted can't be double-delivered.
*/
export class SmtpProvider implements CapabilityProvider {
readonly name = 'smtp';
readonly channelTypes = ['EMAIL'];
readonly capabilities = { canSend: true };
private readonly makeTransport: (id: SmtpIdentity) => MailTransport;
constructor(
private readonly primary: SmtpIdentity,
private readonly fallback?: SmtpIdentity,
makeTransport?: (id: SmtpIdentity) => MailTransport,
private readonly resolveAttachment?: AttachmentResolver,
) {
this.makeTransport = makeTransport ?? defaultTransport;
}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
const p = (req.payload ?? {}) as EmailPayload;
// Resolve attachment bytes up front. Fail CLOSED — never send an invoice/receipt email missing
// its file; a FAILED command retries instead. Resolved once so a fallback retry doesn't re-fetch.
let attachments: MailAttachment[] | undefined;
if (p.attachments && p.attachments.length > 0) {
if (!this.resolveAttachment) return this.failed('NO_ATTACHMENT_RESOLVER', started);
const out: MailAttachment[] = [];
for (const a of p.attachments) {
const r = await this.resolveAttachment(a.contentRef).catch(() => null);
if (!r) return this.failed('ATTACHMENT_UNRESOLVED', started);
out.push({ filename: a.filename ?? r.filename ?? 'attachment', content: r.content, ...(a.mimeType ?? r.contentType ? { contentType: a.mimeType ?? r.contentType } : {}) });
}
attachments = out;
}
const attempt = async (id: SmtpIdentity): Promise<{ messageId: string }> =>
this.makeTransport(id).sendMail({
from: id.from,
to: req.target,
subject: p.subject ?? '(no subject)',
text: p.text,
html: p.html,
...(p.inReplyTo ? { inReplyTo: p.inReplyTo, references: p.inReplyTo } : {}),
...(attachments ? { attachments } : {}),
});
try {
const info = await attempt(this.primary);
return { providerRef: info.messageId, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err) {
// Retry via the fallback identity ONLY if the primary never got the message accepted.
if (this.fallback && isPreAcceptanceFailure(err)) {
try {
const info = await attempt(this.fallback);
return { providerRef: `fallback:${info.messageId}`, outcome: 'SENT', latencyMs: Date.now() - started };
} catch (err2) {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err2), latencyMs: Date.now() - started };
}
}
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode: codeOf(err), latencyMs: Date.now() - started };
}
}
private failed(errorCode: string, started: number): ProviderResult {
return { providerRef: `smtp-error-${randomUUID().slice(0, 8)}`, outcome: 'FAILED', errorCode, latencyMs: Date.now() - started };
}
}
/** True for connect/auth/timeout errors (server never accepted); false once the server responded 2xx. */
function isPreAcceptanceFailure(err: unknown): boolean {
const e = err as { code?: string; responseCode?: number };
const preCodes = ['ECONNECTION', 'ETIMEDOUT', 'ECONNREFUSED', 'EDNS', 'EAUTH', 'ESOCKET', 'EENVELOPE'];
if (e.code && preCodes.includes(e.code)) return true;
// A responseCode present means the server spoke — treat 5xx after acceptance as terminal (no retry).
return e.responseCode == null && e.code == null;
}
function codeOf(err: unknown): string {
const e = err as { code?: string; message?: string };
return (e.code ?? e.message ?? 'SMTP_ERROR').slice(0, 60);
}
function defaultTransport(id: SmtpIdentity): MailTransport {
return createTransport({ host: id.host, port: id.port, secure: id.secure, auth: { user: id.user, pass: id.pass } }) as unknown as MailTransport;
}
@@ -1,6 +1,7 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsInt,
IsISO8601,
IsObject,
IsOptional,
@@ -40,6 +41,7 @@ class PartDto {
@IsOptional() @IsString() bodyText?: string;
@IsOptional() @IsString() contentRef?: string;
@IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
}
/** Validates the POST /v1/interactions/ingest body (conforms to IngestInteractionRequest). */
@@ -178,6 +178,7 @@ export class IngestService {
bodyText: p.bodyText,
contentRef: p.contentRef,
mimeType: p.mimeType,
sizeBytes: p.sizeBytes != null ? BigInt(p.sizeBytes) : undefined,
})),
});
@@ -0,0 +1,57 @@
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
import { SessionVerifier } from '../platform/session.verifier';
import type { MessagePrincipal } from '../identity/actor.resolver';
import { MailService } from './mail.service';
import { MailInternalDto, MailSendDto } from './mail.dto';
import type { TemplateSource } from '../templates/template.model';
@Controller('v1/mail')
export class MailController {
constructor(
private readonly mail: MailService,
private readonly session: SessionVerifier,
) {}
/** App-to-app mail — renders a template and posts it into the recipient's in-app inbox (no SMTP). */
@Post('internal')
async internal(@Body() body: MailInternalDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
return this.mail.postInternal(principal, {
source: this.source(body),
recipientUserId: body.recipientUserId,
vars: body.vars ?? {},
...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey,
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
});
}
/** External email (SMTP) + an in-app mirror when a registered recipient is named. */
@Post('send')
async send(@Body() body: MailSendDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
return this.mail.sendExternalWithMirror(principal, {
source: this.source(body),
target: body.target,
vars: body.vars ?? {},
...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey,
...(body.purpose ? { purpose: body.purpose } : {}),
...(body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}),
...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}),
});
}
private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource {
if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both');
if (body.inline) return { inline: body.inline };
if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key };
throw new BadRequestException('one of "key" or "inline" is required');
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,41 @@
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
import { InlineTemplateDto } from '../templates/template.dto';
/** An email attachment reference — bytes live in the media store under `contentRef`. */
export class AttachmentDto {
@IsOptional() @IsString() filename?: string;
@IsString() @IsNotEmpty() contentRef!: string;
@IsOptional() @IsString() mimeType?: string;
@IsOptional() @IsInt() sizeBytes?: number;
}
/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */
export class MailInternalDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsString() @IsNotEmpty() recipientUserId!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
/** In-app attachment refs — stored as message parts so the recipient's inbox can render them. */
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
}
/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */
export class MailSendDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsString() @IsNotEmpty() target!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
@IsOptional() @IsString() purpose?: string;
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => AttachmentDto) attachments?: AttachmentDto[];
/** The registered recipient to mirror to; omit for a pre-registration send (email only). */
@IsOptional() @IsString() mirrorToUserId?: string;
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TemplateModule } from '../templates/template.module';
import { InteractionsModule } from '../interactions/interactions.module';
import { MailService } from './mail.service';
import { MailController } from './mail.controller';
/**
* Mail orchestration: render a template (TemplateModule) → deliver it either app-to-app (an EMAIL
* interaction via IngestService) or externally (TemplatedSender/SMTP) with an in-app mirror.
* SessionVerifier + ActorResolver are global.
*/
@Module({
imports: [TemplateModule, InteractionsModule],
controllers: [MailController],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
@@ -0,0 +1,121 @@
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 { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { IngestService } from '../interactions/ingest.service';
import { MessageService } from '../messaging/message.service';
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 '../templates/template.repository';
import { TemplateService } from '../templates/template.service';
import { TemplatedSender } from '../templates/templated-sender';
import { MailService, contentToParts } from './mail.service';
import type { PrismaService } from '../prisma/prisma.service';
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios_test?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const actors = new ActorResolver(asService);
function mail(): MailService {
const templates = new TemplateService(new TemplateRepository(asService));
const ingest = new IngestService(asService, makeFakePorts(), actors);
const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const sender = new TemplatedSender(templates, outbound, makeFakePorts());
return new MailService(templates, ingest, sender, actors, asService);
}
const messages = () => new MessageService(asService, makeFakePorts(), actors);
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
const inline = { inline: { subject: 'Welcome Dana', html: '<p>Hi <b>Dana</b></p>', text: 'Hi Dana', variables: [] } };
beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });
describe('contentToParts (pure)', () => {
it('produces HTML + TEXT parts and the subject', () => {
expect(contentToParts({ subject: 'S', html: '<p>h</p>', text: 't' })).toEqual({ subject: 'S', parts: [{ kind: 'HTML', bodyText: '<p>h</p>' }, { kind: 'TEXT', bodyText: 't' }] });
});
it('never yields zero parts (empty text fallback)', () => {
expect(contentToParts({ subject: 'S' }).parts).toEqual([{ kind: 'TEXT', bodyText: '' }]);
});
});
describe('MailService.postInternal', () => {
it('creates an EMAIL interaction and makes the thread visible to BOTH sender and recipient', async () => {
const res = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:1' });
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
expect(interaction.kind).toBe('EMAIL');
expect(interaction.parts.map((p) => p.kind).sort()).toEqual(['HTML', 'TEXT']);
const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: res.threadId } });
expect(thread.subject).toBe('Welcome Dana');
expect((thread.metadata as { source?: string } | null)?.source).toBe('crm-mail'); // lists separately from chat
// The load-bearing assertion: the RECIPIENT can see the thread in their inbox.
const bobThreads = await messages().listThreads(bob);
expect(bobThreads.map((t) => t.threadId)).toContain(res.threadId);
// ...and so can the sender.
const aliceThreads = await messages().listThreads(alice);
expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId);
});
it('stores an in-app attachment as a media part alongside the body', async () => {
const res = await mail().postInternal(alice, {
source: inline, recipientUserId: 'bob', idempotencyKey: 'int:att',
attachments: [{ filename: 'roof.png', contentRef: 'scope/roof.png', mimeType: 'image/png', sizeBytes: 2048 }],
});
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } });
const file = interaction.parts.find((p) => p.contentRef);
expect(file).toBeDefined();
expect(file!.kind).toBe('MEDIA_REF'); // image/* → MEDIA_REF
expect(file!.contentRef).toBe('scope/roof.png');
expect(file!.mimeType).toBe('image/png');
expect(file!.sizeBytes != null ? Number(file!.sizeBytes) : null).toBe(2048);
});
it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => {
const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' });
expect(b.threadId).toBe(a.threadId);
expect(await prisma.iiosInteraction.count({ where: { threadId: a.threadId } })).toBe(1);
});
});
describe('MailService.sendExternalWithMirror', () => {
const ext = { source: inline, target: 'dana@acme.com', idempotencyKey: 'recv:1' } as const;
it('sends via the outbound pipeline AND mirrors into a registered recipient inbox', async () => {
const res = await mail().sendExternalWithMirror(alice, { ...ext, mirrorToUserId: 'bob' });
// outbound command exists (SENT via sandbox in tests)
const cmd = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id: res.commandId } });
expect(cmd.channelType).toBe('EMAIL');
expect(cmd.target).toBe('dana@acme.com');
// mirror interaction visible to the recipient
expect(res.mirror).toBeDefined();
const bobThreads = await messages().listThreads(bob);
expect(bobThreads.map((t) => t.threadId)).toContain(res.mirror!.threadId);
});
it('does NOT mirror when there is no registered recipient (pre-registration send)', async () => {
const res = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:2' }); // no mirrorToUserId
expect(res.mirror).toBeUndefined();
// an outbound command was created, but no mirror interaction
expect(await prisma.iiosOutboundCommand.count({ where: { id: res.commandId } })).toBe(1);
expect(await prisma.iiosInteraction.count()).toBe(0);
});
it('is idempotent — a replay yields one command and one mirror', async () => {
const a = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' });
const b = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' });
expect(b.commandId).toBe(a.commandId);
expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'recv:dup' } })).toBe(1);
expect(await prisma.iiosInteraction.count({ where: { threadId: a.mirror!.threadId } })).toBe(1);
});
});
@@ -0,0 +1,133 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { IngestService } from '../interactions/ingest.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { TemplateService } from '../templates/template.service';
import { TemplatedSender } from '../templates/templated-sender';
import type { RenderedContent } from '../templates/template.renderer';
import type { TemplateSource } from '../templates/template.model';
export interface MailPart { kind: 'HTML' | 'TEXT'; bodyText: string }
/** Turn rendered content into ingest parts (HTML + TEXT) + the thread subject. Pure. */
export function contentToParts(content: RenderedContent): { subject?: string; parts: MailPart[] } {
const parts: MailPart[] = [];
if (content.html != null) parts.push({ kind: 'HTML', bodyText: content.html });
if (content.text != null) parts.push({ kind: 'TEXT', bodyText: content.text });
// A message must carry at least one part; fall back to an empty text part rather than fail ingest.
if (parts.length === 0) parts.push({ kind: 'TEXT', bodyText: '' });
return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
}
export interface MailAttachment { filename?: string; contentRef: string; mimeType?: string; sizeBytes?: number }
export interface PostInternalInput {
source: TemplateSource;
recipientUserId: string;
vars?: Record<string, unknown>;
locale?: string;
idempotencyKey: string;
/** In-app attachment refs — stored as FILE message parts alongside the body. */
attachments?: MailAttachment[];
}
export interface SendExternalInput {
source: TemplateSource;
target: string; // email address (external)
vars?: Record<string, unknown>;
locale?: string;
idempotencyKey: string;
purpose?: string;
/** Attachment refs (bytes resolved at send time). */
attachments?: Array<{ filename?: string; contentRef: string; mimeType?: string }>;
/** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */
mirrorToUserId?: string;
}
/**
* Mail orchestration: render a template, then deliver it.
* - INTERNAL (app-to-app): create an EMAIL interaction on a per-email thread no SMTP.
* - EXTERNAL: send via SMTP (TemplatedSender) AND mirror a copy into the recipient's in-app inbox,
* but only when they are a registered user (pre-registration sends have no inbox yet).
*
* ingest() writes the interaction + thread but adds no participants, and a thread is only visible to
* its participants so after each ingest we add BOTH the sender and the recipient as participants.
*/
@Injectable()
export class MailService {
constructor(
private readonly templates: TemplateService,
private readonly ingest: IngestService,
private readonly sender: TemplatedSender,
private readonly actors: ActorResolver,
private readonly prisma: PrismaService,
) {}
/** Marks a thread as CRM mail so it lists separately from chat (Messenger uses source=crm-messenger). */
static readonly SOURCE = 'crm-mail';
async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> {
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) });
return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL', input.attachments);
}
async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> {
const command = await this.sender.sendTemplated({
source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {},
...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}),
...(input.attachments && input.attachments.length > 0 ? { attachments: input.attachments } : {}),
});
// Mirror only for a registered recipient (timing rule: no inbox exists pre-registration).
if (!input.mirrorToUserId) return { commandId: command.id };
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) });
const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL', input.attachments);
return { commandId: command.id, mirror };
}
/** A stored media ref → a message part; kind follows the mime (image/video → MEDIA_REF, audio → VOICE_REF, else FILE_REF). */
private attachmentPart(a: MailAttachment): { kind: 'MEDIA_REF' | 'VOICE_REF' | 'FILE_REF'; bodyText?: string; contentRef: string; mimeType?: string; sizeBytes?: number } {
const mime = a.mimeType ?? '';
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
return { kind, ...(a.filename ? { bodyText: a.filename } : {}), contentRef: a.contentRef, ...(a.mimeType ? { mimeType: a.mimeType } : {}), ...(a.sizeBytes != null ? { sizeBytes: a.sizeBytes } : {}) };
}
/** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */
private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string, attachments?: MailAttachment[]): Promise<{ threadId: string; interactionId: string }> {
const { subject, parts } = contentToParts(content);
const allParts = [...parts, ...(attachments ?? []).map((a) => this.attachmentPart(a))];
const res = await this.ingest.ingest(
{
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) },
channel: { type: channelType, externalChannelId: channelType.toLowerCase() },
source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
kind: 'EMAIL',
thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
parts: allParts,
occurredAt: new Date().toISOString(),
providerEventId: key,
},
key,
);
// Make the thread visible to both the sender and the recipient (ingest adds no participants).
const scope = await this.actors.resolveScope(principal);
const senderActor = await this.actors.resolveActor(scope.id, principal);
const recipientActor = await this.actors.resolveActor(scope.id, {
userId: recipientUserId, appId: principal.appId, orgId: principal.orgId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}),
});
await this.actors.ensureParticipant(res.threadId, senderActor.id);
await this.actors.ensureParticipant(res.threadId, recipientActor.id);
// Tag the thread as CRM mail (thread metadata) so it lists separately from Messenger chat.
// ingest() puts req.metadata on the interaction, not the thread, so we set it here directly.
await this.prisma.iiosThread.update({
where: { id: res.threadId },
data: { metadata: { source: MailService.SOURCE } as Prisma.InputJsonValue },
});
return { threadId: res.threadId, interactionId: res.interactionId };
}
}
@@ -5,6 +5,9 @@ import { SessionVerifier } from '../platform/session.verifier';
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
import type { MessagePrincipal } from '../identity/actor.resolver';
/** Types that can execute script if a browser renders them top-level — served as downloads only. */
const SCRIPTABLE_MIMES = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml', 'text/xml', 'application/xml']);
@Controller('v1/media')
export class MediaController {
constructor(
@@ -37,6 +40,12 @@ export class MediaController {
async blob(@Param('token') token: string, @Res() res: Response) {
const { data, mime } = await this.media.get(token);
res.setHeader('Content-Type', mime);
// Never let the browser MIME-sniff an upload into something executable.
res.setHeader('X-Content-Type-Options', 'nosniff');
// Script-capable types must not render inline from our origin (stored-XSS) — force a download.
// Images/video/audio/pdf stay inline so the app can preview them. Note <img>/<video> still embed
// fine even with attachment disposition; only top-level navigation to the blob is affected.
if (SCRIPTABLE_MIMES.has(mime)) res.setHeader('Content-Disposition', 'attachment');
res.setHeader('Cache-Control', 'private, max-age=3600');
res.send(data);
}
@@ -1,15 +1,27 @@
import { Module } from '@nestjs/common';
import { Module, Logger } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { IdentityModule } from '../identity/identity.module';
import { MediaController } from './media.controller';
import { MediaService } from './media.service';
import { LocalDiskStorage } from './local-disk.storage';
import { STORAGE_PORT } from './storage.port';
import { S3Storage, s3ConfigFromEnv } from './s3.storage';
import { STORAGE_PORT, type StoragePort } from './storage.port';
/** S3/MinIO when its env is set (IIOS_S3_BUCKET + keys), else local disk. Env-driven swap. */
function makeStorage(): StoragePort {
const cfg = s3ConfigFromEnv();
if (cfg) {
new Logger('MediaStorage').log(`using S3 storage (bucket "${cfg.bucket}"${cfg.endpoint ? ` @ ${cfg.endpoint}` : ''})`);
return new S3Storage(cfg);
}
return new LocalDiskStorage();
}
@Module({
imports: [PlatformModule, IdentityModule],
controllers: [MediaController],
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter.
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }],
providers: [MediaService, { provide: STORAGE_PORT, useFactory: makeStorage }],
// Exported so the capability layer can resolve email attachment bytes at send time.
exports: [STORAGE_PORT],
})
export class MediaModule {}
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { S3Storage, s3ConfigFromEnv, type S3Config, type S3Like } from './s3.storage';
const CFG: S3Config = { endpoint: 'https://minio.test', region: 'us-east-1', bucket: 'iios', accessKeyId: 'k', secretAccessKey: 's', forcePathStyle: true };
/** A recording stub S3 client; GetObject returns whatever `store[key]` holds (or a NoSuchKey error). */
function stub(store: Record<string, { body: Buffer; mime: string }> = {}) {
const calls: unknown[] = [];
const client: S3Like = {
async send(command: unknown) {
calls.push(command);
if (command instanceof PutObjectCommand) { store[command.input.Key!] = { body: command.input.Body as Buffer, mime: command.input.ContentType ?? '' }; return {}; }
if (command instanceof DeleteObjectCommand) { delete store[command.input.Key!]; return {}; }
if (command instanceof GetObjectCommand) {
const hit = store[command.input.Key!];
if (!hit) throw Object.assign(new Error('missing'), { name: 'NoSuchKey' });
return { Body: { transformToByteArray: async () => new Uint8Array(hit.body) }, ContentType: hit.mime };
}
return {};
},
};
return { client, calls, store };
}
describe('s3ConfigFromEnv', () => {
it('builds config from env (path-style default true for MinIO)', () => {
const cfg = s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x', IIOS_S3_BUCKET: 'b', IIOS_S3_ACCESS_KEY: 'k', IIOS_S3_SECRET_KEY: 's' } as NodeJS.ProcessEnv);
expect(cfg).toMatchObject({ endpoint: 'https://minio.x', bucket: 'b', region: 'us-east-1', forcePathStyle: true });
});
it('returns null when bucket/keys are missing', () => {
expect(s3ConfigFromEnv({ IIOS_S3_ENDPOINT: 'https://minio.x' } as NodeJS.ProcessEnv)).toBeNull();
});
});
describe('S3Storage (StoragePort over S3/MinIO)', () => {
it('put stores the object and returns size + sha256', async () => {
const s = stub();
const store = new S3Storage(CFG, s.client);
const res = await store.put('scope/obj1', Buffer.from('hello'), 'text/plain');
expect(res.sizeBytes).toBe(5);
expect(res.checksumSha256).toMatch(/^[a-f0-9]{64}$/);
const put = s.calls[0] as PutObjectCommand;
expect(put.input).toMatchObject({ Bucket: 'iios', Key: 'scope/obj1', ContentType: 'text/plain' });
});
it('get round-trips the bytes + mime', async () => {
const s = stub();
const store = new S3Storage(CFG, s.client);
await store.put('scope/obj2', Buffer.from('PDFDATA'), 'application/pdf');
const got = await store.get('scope/obj2');
expect(got).toEqual({ data: Buffer.from('PDFDATA'), mime: 'application/pdf', sizeBytes: 7 });
});
it('get returns null for a missing key (NoSuchKey → null, not throw)', async () => {
const store = new S3Storage(CFG, stub().client);
expect(await store.get('nope')).toBeNull();
});
it('remove issues a DeleteObject', async () => {
const s = stub({ 'k': { body: Buffer.from('x'), mime: 't' } });
await new S3Storage(CFG, s.client).remove('k');
expect(s.calls.some((c) => c instanceof DeleteObjectCommand)).toBe(true);
expect(s.store['k']).toBeUndefined();
});
});
@@ -0,0 +1,87 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import type { StoragePort } from './storage.port';
export interface S3Config {
endpoint?: string; // MinIO/self-hosted URL; omit for AWS
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
forcePathStyle: boolean; // true for MinIO
}
/** The one method this adapter uses — lets tests inject a stub client (no live S3/MinIO). */
export interface S3Like {
send(command: unknown): Promise<unknown>;
}
/** Build an S3Config from env, or null if the required bits are missing (→ fall back to disk). */
export function s3ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): S3Config | null {
const bucket = env.IIOS_S3_BUCKET;
const accessKeyId = env.IIOS_S3_ACCESS_KEY;
const secretAccessKey = env.IIOS_S3_SECRET_KEY;
if (!bucket || !accessKeyId || !secretAccessKey) return null;
return {
...(env.IIOS_S3_ENDPOINT ? { endpoint: env.IIOS_S3_ENDPOINT } : {}),
region: env.IIOS_S3_REGION ?? 'us-east-1',
bucket,
accessKeyId,
secretAccessKey,
// MinIO/self-hosted needs path-style; default true unless explicitly disabled for AWS.
forcePathStyle: env.IIOS_S3_FORCE_PATH_STYLE !== 'false',
};
}
/**
* S3-compatible object storage (AWS S3, MinIO, R2, Supabase Storage). A drop-in for LocalDiskStorage:
* same StoragePort contract. sha256 is computed locally on put (S3 doesn't return it), matching the
* disk adapter. A missing object reads back as null (not an error).
*/
@Injectable()
export class S3Storage implements StoragePort {
private readonly client: S3Like;
private readonly bucket: string;
constructor(config: S3Config, client?: S3Like) {
this.bucket = config.bucket;
this.client = client ?? new S3Client({
region: config.region,
...(config.endpoint ? { endpoint: config.endpoint } : {}),
forcePathStyle: config.forcePathStyle,
credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey },
});
}
async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> {
const checksumSha256 = createHash('sha256').update(data).digest('hex');
await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: objectKey, Body: data, ContentType: mime }));
return { sizeBytes: data.length, checksumSha256 };
}
async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> {
try {
const res = (await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: objectKey }))) as {
Body?: { transformToByteArray(): Promise<Uint8Array> };
ContentType?: string;
};
if (!res.Body) return null;
const bytes = await res.Body.transformToByteArray();
const data = Buffer.from(bytes);
return { data, mime: res.ContentType ?? 'application/octet-stream', sizeBytes: data.length };
} catch (err) {
if (isNotFound(err)) return null;
throw err;
}
}
async remove(objectKey: string): Promise<void> {
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: objectKey }));
}
}
function isNotFound(err: unknown): boolean {
const e = err as { name?: string; $metadata?: { httpStatusCode?: number } };
return e.name === 'NoSuchKey' || e.name === 'NotFound' || e.$metadata?.httpStatusCode === 404;
}
@@ -159,6 +159,75 @@ export class MessageService {
return { threadId, participantCount: participantCount + 1 };
}
/**
* Governed thread rename (a generic subject update). Policy decides who may rename for a
* membership thread the dev OPA requires the caller be a group ADMIN. The kernel only writes
* the subject; "group settings" meaning lives in the app + policy, not here.
*/
async renameThread(threadId: string, principal: MessagePrincipal, subject: string): Promise<{ threadId: string; subject: string }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, {
action: 'iios.thread.update',
threadId,
scopeId: thread.scopeId,
membership,
callerRole: callerP?.participantRole,
});
await this.prisma.iiosThread.update({ where: { id: threadId }, data: { subject } });
return { threadId, subject };
}
/**
* Governed participant removal. Policy decides who may remove for a membership thread the dev
* OPA requires the caller be a group ADMIN. Removing a non-participant is a no-op success.
*/
async removeParticipant(threadId: string, principal: MessagePrincipal, targetUserId: string): Promise<{ threadId: string; participantCount: number }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const caller = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: caller.id } } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, {
action: 'iios.thread.participant.remove',
threadId,
scopeId: thread.scopeId,
membership,
callerRole: callerP?.participantRole,
targetUserId,
});
const scope = await this.actors.resolveScope(principal);
const target = await this.actors.resolveActor(scope.id, {
userId: targetUserId, appId: principal.appId, orgId: principal.orgId, tenantId: principal.tenantId, displayName: targetUserId,
});
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: target.id } });
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
return { threadId, participantCount };
}
/** Members of a thread with their role — drives the group settings member list. Read is policy-scoped. */
async listParticipants(threadId: string, principal: MessagePrincipal): Promise<Array<{ userId: string; displayName: string; role: string }>> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
await decideOrThrow(this.ports, { action: 'iios.thread.read', threadId, scopeId: thread.scopeId });
const parts = await this.prisma.iiosThreadParticipant.findMany({
where: { threadId },
include: { actor: { include: { sourceHandle: true } } },
});
return parts.map((p) => ({
userId: p.actor?.sourceHandle?.externalId ?? '',
displayName: p.actor?.displayName ?? p.actor?.sourceHandle?.externalId ?? 'unknown',
role: p.participantRole ?? 'MEMBER',
}));
}
/** Toggle the caller's per-thread notification mute flag. */
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
@@ -124,6 +124,28 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
await expect(s.addParticipant(threadId, bob, 'carol')).rejects.toBeInstanceOf(PolicyDeniedError); // bob is MEMBER
});
it('group settings: admin renames + lists members + removes; a plain member cannot rename/remove', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN', subject: 'Design' });
await s.addParticipant(threadId, alice, 'bob');
// admin renames
expect((await s.renameThread(threadId, alice, 'Design Team')).subject).toBe('Design Team');
// a plain member cannot rename
await expect(s.renameThread(threadId, bob, 'Hacked')).rejects.toBeInstanceOf(PolicyDeniedError);
// member list carries roles
const members = await s.listParticipants(threadId, alice);
expect(members.map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
expect(members.find((m) => m.userId === 'alice')?.role).toBe('ADMIN');
// a plain member cannot remove
await expect(s.removeParticipant(threadId, bob, 'alice')).rejects.toBeInstanceOf(PolicyDeniedError);
// admin removes bob
expect((await s.removeParticipant(threadId, alice, 'bob')).participantCount).toBe(1);
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
});
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
@@ -33,6 +33,35 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
expect(asAdmin.allow).toBe(true);
});
it('group rename (thread.update) requires ADMIN; ungoverned threads allow it', async () => {
const asMember = await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'MEMBER' });
expect(asMember.allow).toBe(false);
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
expect((await opa.decide({ action: 'iios.thread.update', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
expect((await opa.decide({ action: 'iios.thread.update' })).allow).toBe(true); // no membership attr → allow
});
it('group remove requires ADMIN; a member on an ungoverned thread may remove', async () => {
const asMember = await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'MEMBER' });
expect(asMember.allow).toBe(false);
expect(asMember.obligations[0]?.reason).toMatch(/admin/);
expect((await opa.decide({ action: 'iios.thread.participant.remove', membership: 'group', callerRole: 'ADMIN' })).allow).toBe(true);
expect((await opa.decide({ action: 'iios.thread.participant.remove', callerRole: 'MEMBER' })).allow).toBe(true);
});
it('media upload allows images + docs (incl. html/markdown/csv), denies unknown types and oversize', async () => {
const up = (mime: string, sizeBytes = 1024) => opa.decide({ action: 'iios.media.upload', mime, sizeBytes });
for (const mime of ['image/png', 'video/mp4', 'audio/mpeg', 'application/pdf', 'text/plain', 'text/markdown', 'text/html', 'text/csv']) {
expect((await up(mime)).allow).toBe(true);
}
const bad = await up('application/x-msdownload');
expect(bad.allow).toBe(false);
expect(bad.obligations[0]?.reason).toMatch(/not allowed/);
const big = await up('image/png', 30 * 1024 * 1024);
expect(big.allow).toBe(false);
expect(big.obligations[0]?.reason).toMatch(/too large/);
});
it('self-join is governed only on membership threads', async () => {
// generic / support thread (no membership attr) → open join, unchanged
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
@@ -22,7 +22,7 @@ export interface OpaInput {
const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
const MEDIA_ALLOWED = /^(image|video|audio)\//;
const MEDIA_ALLOWED_DOCS = new Set([
'application/pdf', 'text/plain', 'application/zip',
'application/pdf', 'text/plain', 'text/markdown', 'text/x-markdown', 'text/html', 'text/csv', 'application/zip',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
@@ -42,6 +42,18 @@ export class DevOpaPort {
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
return allow();
}
case 'iios.thread.participant.remove': {
// Same governance as add: for a group, only an ADMIN removes members. Ungoverned
// (no membership attr) threads allow removal by any member.
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can add or remove members');
if (i.membership && i.callerRole !== 'MEMBER' && i.callerRole !== 'ADMIN') return deny('only a member can remove participants');
return allow();
}
case 'iios.thread.update': {
// Rename / settings change: for a group, only an ADMIN. Ungoverned threads allow it.
if (i.membership === 'group' && i.callerRole !== 'ADMIN') return deny('only a group admin can change group settings');
return allow();
}
case 'iios.thread.join': {
// Only threads that opted into a membership model (chat dm/group) are governed;
// generic/support threads (no membership attr) keep open-join. For a governed
@@ -117,3 +117,59 @@ describe('RetentionService (P9 — retention sweep)', () => {
expect(await bodyOf(b.interactionId)).toBe('tenant b'); // scope B untouched
});
});
// T8: an outbound email/SMS command holds PII (the recipient address, and the rendered body with
// their name). Once aged, redact those while KEEPING the template provenance for audit/replay.
describe('RetentionService — outbound command PII (T8)', () => {
const setOutboundSnapshot = (targetId: string, data: { archiveAfter?: Date; deleteAfter?: Date }) =>
prisma.iiosRetentionPolicySnapshot.update({ where: { targetType_targetId: { targetType: 'outbound_command', targetId } }, data });
async function command(target: string): Promise<{ id: string; scopeId: string }> {
const scope = await prisma.iiosScope.create({ data: { orgId: 'org_demo', appId: 'crm-web' } });
const cmd = await prisma.iiosOutboundCommand.create({
data: {
channelType: 'EMAIL', target, scopeId: scope.id, status: 'SENT', idempotencyKey: `k-${randomUUID()}`,
payload: { subject: 'Hi Dana', html: '<p>secret body</p>' },
templateKey: 'welcome', templateVersion: 1, templateLocale: 'en', renderedHash: 'abc123',
},
});
return { id: cmd.id, scopeId: scope.id };
}
it('ensureOutboundSnapshots captures one snapshot per outbound command', async () => {
const { id } = await command('dana@acme.com');
const n = await svc().ensureOutboundSnapshots();
expect(n).toBe(1);
const snap = await prisma.iiosRetentionPolicySnapshot.findUniqueOrThrow({ where: { targetType_targetId: { targetType: 'outbound_command', targetId: id } } });
expect(snap.targetType).toBe('outbound_command');
expect(snap.dataClass).toBe('outbound');
});
it('redacts target + payload of an aged command but keeps template provenance', async () => {
const { id } = await command('dana@acme.com');
await svc().ensureOutboundSnapshots();
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
const res = await svc().applySweep();
expect(res.redacted).toBe(1);
const after = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } });
expect(after.target).toBe('[redacted]');
expect(after.payload).toEqual({ redacted: true });
// Provenance survives — you can still answer "which template version did we send?"
expect(after.templateKey).toBe('welcome');
expect(after.templateVersion).toBe(1);
expect(after.renderedHash).toBe('abc123');
expect(await prisma.iiosAuditLink.count({ where: { action: 'retention.redacted', resourceType: 'outbound_command' } })).toBe(1);
});
it('an active compliance hold blocks the command sweep', async () => {
const { id, scopeId } = await command('held@acme.com');
await svc().ensureOutboundSnapshots();
await setOutboundSnapshot(id, { archiveAfter: past, deleteAfter: past });
await prisma.iiosComplianceHold.create({ data: { scopeId, targetType: 'outbound_command', targetId: id, holdReason: 'legal' } });
const res = await svc().applySweep();
expect(res.skippedHeld).toBe(1);
expect((await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id } })).target).toBe('held@acme.com');
});
});
@@ -72,6 +72,41 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
return created;
}
/**
* Capture a retention snapshot for any outbound command that lacks one. An email/SMS command
* holds PII (the recipient address, and the rendered body with their name), so it gets a single
* window and goes STRAIGHT to redact (archiveAfter == deleteAfter no archive phase). Unscoped
* system sends use an 'unscoped' partition tag so the global sweep still reaches them.
*/
async ensureOutboundSnapshots(scopeId?: string): Promise<number> {
const commands = await this.prisma.iiosOutboundCommand.findMany({
where: scopeId ? { scopeId } : {},
select: { id: true, scopeId: true, createdAt: true },
});
let created = 0;
for (const c of commands) {
const exists = await this.prisma.iiosRetentionPolicySnapshot.findUnique({
where: { targetType_targetId: { targetType: 'outbound_command', targetId: c.id } },
});
if (exists) continue;
const deleteAt = new Date(c.createdAt.getTime() + this.windowDays('outbound', 'DELETE') * DAY_MS);
await this.prisma.iiosRetentionPolicySnapshot.create({
data: {
policyKey: 'outbound:pii:v1',
scopeSnapshotId: c.scopeId ?? 'unscoped',
targetType: 'outbound_command',
targetId: c.id,
dataClass: 'outbound',
archiveAfter: deleteAt,
deleteAfter: deleteAt,
sourceVersion: process.env.IIOS_RETENTION_POLICY_VERSION ?? 'v1',
},
});
created++;
}
return created;
}
/** Act on due snapshots: archive, or delete (redact-in-place), honoring compliance holds. */
async applySweep(scopeId?: string): Promise<SweepResult> {
const now = new Date();
@@ -90,6 +125,14 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
}
if (s.deleteAfter <= now) {
if (s.targetType === 'outbound_command') {
// Redact the recipient address + rendered body; KEEP the template provenance columns.
await this.prisma.$transaction([
this.prisma.iiosOutboundCommand.update({ where: { id: s.targetId }, data: { target: '[redacted]', payload: { redacted: true } } }),
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
]);
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'outbound_command', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
} else {
await this.prisma.$transaction([
this.prisma.iiosMessagePart.updateMany({ where: { interactionId: s.targetId }, data: { bodyText: '[redacted]', contentRef: null } }),
this.prisma.iiosInboundRawEvent.updateMany({ where: { interactionId: s.targetId }, data: { payload: { redacted: true } } }),
@@ -97,6 +140,7 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'REDACTED' } }),
]);
await recordAudit(this.prisma, { action: 'retention.redacted', resourceType: 'interaction', resourceId: s.targetId, scopeId: s.scopeSnapshotId });
}
result.redacted++;
} else if (s.status === 'ACTIVE') {
await this.prisma.iiosRetentionPolicySnapshot.update({ where: { id: s.id }, data: { status: 'ARCHIVED' } });
@@ -107,9 +151,10 @@ export class RetentionService implements OnModuleInit, OnModuleDestroy {
return result;
}
/** Full sweep: capture missing snapshots, then act on due ones. */
/** Full sweep: capture missing snapshots (interactions + outbound commands), then act on due ones. */
async sweep(scopeId?: string): Promise<SweepResult> {
await this.ensureSnapshots(scopeId);
await this.ensureOutboundSnapshots(scopeId);
return this.applySweep(scopeId);
}
@@ -0,0 +1,79 @@
import type { IiosTemplateChannel } from '@prisma/client';
/** A platform-default template shipped in the repo and seeded at boot (scopeId NULL). */
export interface TemplateSeed {
key: string;
channel: IiosTemplateChannel;
locale: string;
version: number;
subject?: string;
bodyHtml?: string;
bodyText?: string;
variables: string[];
}
// Placeholder copy — marketing (Nikita/Himanshu) owns the words, Gautam the HTML. These are
// functional defaults so the pipeline works end-to-end before final copy lands; bump `version`
// when the content changes (the old version stays for audit/replay).
const WELCOME_EMAIL: TemplateSeed = {
key: 'welcome',
channel: 'EMAIL',
locale: 'en',
version: 1,
subject: 'Welcome to the Founders Club, {{firstName}}',
bodyHtml:
'<p>Hi {{firstName}},</p>' +
'<p>Thanks for joining the Founders Club. Your account is ready to set up.</p>' +
'<p><a href="{{signupLink}}">Create your account</a></p>' +
'<p>We are launching soon — we will keep you posted.</p>',
bodyText:
'Hi {{firstName}},\n\nThanks for joining the Founders Club. Create your account: {{signupLink}}\n\nWe are launching soon.',
variables: ['firstName', 'signupLink'],
};
const PAYMENT_RECEIPT_EMAIL: TemplateSeed = {
key: 'payment.receipt',
channel: 'EMAIL',
locale: 'en',
version: 1,
subject: 'Thanks for your payment, {{firstName}}',
bodyHtml:
'<p>Hi {{firstName}},</p>' +
'<p>We have received your payment of <strong>{{amount}}</strong> for {{licenseCount}} license(s).</p>' +
'<p>A separate tax receipt from our payment processor will follow.</p>',
bodyText:
'Hi {{firstName}},\n\nWe have received your payment of {{amount}} for {{licenseCount}} license(s).\nA separate tax receipt will follow.',
variables: ['firstName', 'amount', 'licenseCount'],
};
const PAYMENT_RECEIPT_SMS: TemplateSeed = {
key: 'payment.receipt',
channel: 'SMS',
locale: 'en',
version: 1,
bodyText: 'Thanks {{firstName}}! We received your payment of {{amount}}. A receipt is on its way to your email.',
variables: ['firstName', 'amount'],
};
const ONBOARDING_REMINDER_EMAIL: TemplateSeed = {
key: 'onboarding.reminder',
channel: 'EMAIL',
locale: 'en',
version: 1,
subject: 'Looks like you have not finished setting up',
bodyHtml:
'<p>Hi {{firstName}},</p>' +
'<p>You paid but have not created your account yet. It only takes a minute.</p>' +
'<p><a href="{{signupLink}}">Finish creating your account</a></p>',
bodyText:
'Hi {{firstName}},\n\nYou paid but have not created your account yet. Finish here: {{signupLink}}',
variables: ['firstName', 'signupLink'],
};
export const TEMPLATE_SEEDS: TemplateSeed[] = [
WELCOME_EMAIL,
PAYMENT_RECEIPT_EMAIL,
PAYMENT_RECEIPT_SMS,
ONBOARDING_REMINDER_EMAIL,
];
@@ -0,0 +1,62 @@
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
import { IiosTemplateChannel } from '@prisma/client';
import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { TemplatedSender, type ExternalChannel } from './templated-sender';
import { TemplateService } from './template.service';
import { PreviewTemplateDto, SendTemplateDto } from './template.dto';
import type { TemplateSource } from './template.model';
@Controller('v1/templates')
export class TemplateController {
constructor(
private readonly sender: TemplatedSender,
private readonly templates: TemplateService,
private readonly session: SessionVerifier,
private readonly actors: ActorResolver,
) {}
/** Render a stored/inline template and queue it for external delivery (EMAIL/SMS). */
@Post('send')
async send(@Body() body: SendTemplateDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
const scope = await this.actors.resolveScope(principal);
return this.sender.sendTemplated({
source: this.source(body),
channel: body.channel as ExternalChannel,
target: body.target,
vars: body.vars ?? {},
scopeId: scope.id,
...(body.locale ? { locale: body.locale } : {}),
idempotencyKey: body.idempotencyKey,
...(body.purpose ? { purpose: body.purpose } : {}),
});
}
/** Render only — no send. For marketing/QA to eyeball copy before it goes out. */
@Post('preview')
async preview(@Body() body: PreviewTemplateDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
const scope = await this.actors.resolveScope(principal);
const { content } = await this.templates.render(this.source(body), body.vars ?? {}, {
channel: body.channel as IiosTemplateChannel,
...(body.locale ? { locale: body.locale } : {}),
scopeId: scope.id,
});
return content;
}
/** Map the DTO's key|inline into a TemplateSource (exactly one must be present). */
private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource {
if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both');
if (body.inline) return { inline: body.inline };
if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key };
throw new BadRequestException('one of "key" or "inline" is required');
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,38 @@
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
const EXTERNAL_CHANNELS = ['EMAIL', 'SMS'] as const;
const ALL_CHANNELS = ['EMAIL', 'SMS', 'INTERNAL'] as const;
/** Inline template content — an ad-hoc source (e.g. finished HTML from marketing). */
export class InlineTemplateDto {
@IsOptional() @IsString() subject?: string;
@IsOptional() @IsString() html?: string;
@IsOptional() @IsString() text?: string;
@IsOptional() @IsString({ each: true }) variables?: string[];
}
/** Body of POST /v1/templates/send. Provide EITHER `key` (stored) OR `inline` (ad-hoc). */
export class SendTemplateDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsIn(EXTERNAL_CHANNELS) channel!: (typeof EXTERNAL_CHANNELS)[number];
@IsString() @IsNotEmpty() target!: string;
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
@IsString() @IsNotEmpty() idempotencyKey!: string;
@IsOptional() @IsString() purpose?: string;
}
/** Body of POST /v1/templates/preview — render only, no send. INTERNAL allowed (preview only). */
export class PreviewTemplateDto {
@IsOptional() @IsString() @IsNotEmpty() key?: string;
@IsOptional() @IsInt() version?: number;
@IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto;
@IsIn(ALL_CHANNELS) channel!: (typeof ALL_CHANNELS)[number];
@IsOptional() @IsObject() vars?: Record<string, unknown>;
@IsOptional() @IsString() locale?: string;
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { AdaptersModule } from '../adapters/adapters.module';
import { TemplateRepository } from './template.repository';
import { TemplateService } from './template.service';
import { TemplatedSender } from './templated-sender';
import { TemplateSeeder } from './template.seeder';
import { TemplateController } from './template.controller';
/**
* Reusable message-template module: render a stored/inline template hand it to the existing
* outbound pipeline (via AdaptersModule's OutboundService). Content lives here; delivery stays in
* the outbound/capability layers. SessionVerifier, ActorResolver (IdentityModule) and PLATFORM_PORTS
* (PlatformModule) are global.
*/
@Module({
imports: [AdaptersModule],
controllers: [TemplateController],
providers: [TemplateRepository, TemplateService, TemplatedSender, TemplateSeeder],
exports: [TemplateService, TemplatedSender],
})
export class TemplateModule {}
@@ -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]);
});
});
@@ -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<void> {
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<number> {
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;
}
}
@@ -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: '<p>{{amount}}</p>', 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: '<b>{{n}}</b>', 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' });
});
});
@@ -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<string, unknown>;
scopeId?: string;
locale?: string;
idempotencyKey: string; // REQUIRED — e.g. "receipt:<stripe_session_id>"
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<string, unknown> {
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
}
}
@@ -3,10 +3,12 @@ import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Headers,
HttpCode,
Param,
Patch,
Post,
Query,
UseGuards,
@@ -56,6 +58,27 @@ export class ThreadsController {
return this.messages.addParticipant(id, this.principal(auth), body.userId, body.role);
}
/** Members of a thread with their role — drives the group settings member list. */
@Get(':id/participants')
async listParticipants(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.listParticipants(id, this.principal(auth));
}
/** Governed removal: remove a user from a thread — policy enforces group-admin. */
@Delete(':id/participants/:userId')
@HttpCode(200)
async removeParticipant(@Param('id') id: string, @Param('userId') userId: string, @Headers('authorization') auth?: string) {
return this.messages.removeParticipant(id, this.principal(auth), userId);
}
/** Governed rename (group settings): change a thread subject — policy enforces group-admin. */
@Patch(':id')
@HttpCode(200)
async updateThread(@Param('id') id: string, @Body() body: { subject?: string }, @Headers('authorization') auth?: string) {
if (body?.subject == null) throw new BadRequestException('subject is required');
return this.messages.renameThread(id, this.principal(auth), body.subject);
}
@Get(':id/messages')
async listMessages(@Param('id') id: string) {
return this.threads.getMessages(id);
@@ -9,7 +9,7 @@ export interface ThreadMessage {
actorId: string | null;
kind: string;
occurredAt: Date;
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>;
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null; mimeType: string | null; sizeBytes: number | null }>;
}
@Injectable()
@@ -39,7 +39,7 @@ export class ThreadsService {
actorId: i.actorId,
kind: i.kind,
occurredAt: i.occurredAt,
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef })),
parts: i.parts.map((p) => ({ kind: p.kind, bodyText: p.bodyText, contentRef: p.contentRef, mimeType: p.mimeType, sizeBytes: p.sizeBytes != null ? Number(p.sizeBytes) : null })),
})),
};
}
+321
View File
@@ -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
@@ -376,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)
@@ -410,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
@@ -481,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'}
@@ -1444,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==}
@@ -1520,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==}
@@ -1757,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==}
@@ -2545,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==}
@@ -3349,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
@@ -4092,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)':
@@ -4195,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': {}
@@ -4475,6 +4792,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
bowser@2.14.1: {}
brace-expansion@1.1.15:
dependencies:
balanced-match: 1.0.2
@@ -5317,6 +5636,8 @@ snapshots:
node-releases@2.0.50: {}
nodemailer@9.0.3: {}
notepack.io@3.0.1: {}
nypm@0.6.8: