Files
iios/docs/inbox-mirror-internal-delivery-plan.md
2026-07-18 13:49:19 +05:30

7.2 KiB

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 Interactions) — 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 ensureParticipants both the sender's actor and the recipient's actor (ActorResolver.resolveActorensureParticipant). 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").