Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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:
- 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 में चाहिए ही चाहिए."
- 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
InboxItemis 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, writesInteraction(kind fromreq.kind) + parts + outbox event, idempotent per (scope, idempotencyKey). Inbound email already uses it to makeEMAILinteractions with HTML/TEXT parts — the exact model for the outbound mirror.TemplateService.render()(exported) →{subject, html, text}.TemplatedSender.sendTemplated()→ SMTP egress (built).IiosMessagePartKindhasHTML+TEXT;IiosInteractionKindhasEMAIL.
Design (locked)
- New
MailService(newsrc/mail/module) orchestratesTemplateService+IngestService+TemplatedSender+ActorResolver. Templates/outbound stay unaware of each other.postInternal(...)— render →ingest()anEMAILinteraction on a per-email thread. No SMTP.sendExternalWithMirror(...)— render →TemplatedSender.sendTemplated()(SMTP) → and mirror viaingest()iff the recipient is a registered user (timing rule below).
- Visibility (resolved review finding):
ingest()creates the interaction + thread but adds NO participants, andlistThreadsshows only threads where the caller is a participant. So after each ingest the MailServiceensureParticipants both the sender's actor and the recipient's actor (ActorResolver.resolveActor→ensureParticipant). Without this the mirror is invisible. ingest()returnsthreadId— used directly to add the two participants.- Rendered content → parts: part 0
HTML(bodyHtml), part 1TEXT(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:
parentInteractionIdfor in-thread replies (already modeled); a mirrored email'sinReplyTomaps 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
EMAILinteractions 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 explicitensureParticipantafter 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").