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