# 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").
