diff --git a/docs/inbox-mirror-internal-delivery-plan.md b/docs/inbox-mirror-internal-delivery-plan.md new file mode 100644 index 0000000..fb9d1fc --- /dev/null +++ b/docs/inbox-mirror-internal-delivery-plan.md @@ -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:`), 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").