feat(mail): inbox mirror + INTERNAL (app-to-app) delivery

MailService renders a template then deposits it as an EMAIL interaction on a
per-email thread (thread model: one thread per email), reusing IngestService.
Because ingest adds no participants and a thread is only visible to its
participants, it ensureParticipant()s BOTH sender and recipient — so the mirror
is actually visible.

- postInternal: app-to-app mail, no SMTP → recipient's in-app inbox.
- sendExternalWithMirror: SMTP send (TemplatedSender) + mirror an interaction
  ONLY for a registered recipient (pre-registration sends are email-only — no
  inbox exists yet). Idempotent across both the send and the mirror.
- Writes Interactions, NEVER InboxItems (the projector owns those — KG-15).
- POST /v1/mail/internal, POST /v1/mail/send. MailModule in AppModule.

Verified over HTTP: internal mail → recipient SEES the thread in their inbox;
external send → command + mirror; no-source/no-auth 400. 7 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 13:54:32 +05:30
parent bba5fae061
commit 9b075f46f9
6 changed files with 316 additions and 0 deletions
@@ -0,0 +1,105 @@
import { Injectable } from '@nestjs/common';
import { IngestService } from '../interactions/ingest.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { TemplateService } from '../templates/template.service';
import { TemplatedSender } from '../templates/templated-sender';
import type { RenderedContent } from '../templates/template.renderer';
import type { TemplateSource } from '../templates/template.model';
export interface MailPart { kind: 'HTML' | 'TEXT'; bodyText: string }
/** Turn rendered content into ingest parts (HTML + TEXT) + the thread subject. Pure. */
export function contentToParts(content: RenderedContent): { subject?: string; parts: MailPart[] } {
const parts: MailPart[] = [];
if (content.html != null) parts.push({ kind: 'HTML', bodyText: content.html });
if (content.text != null) parts.push({ kind: 'TEXT', bodyText: content.text });
// A message must carry at least one part; fall back to an empty text part rather than fail ingest.
if (parts.length === 0) parts.push({ kind: 'TEXT', bodyText: '' });
return { ...(content.subject != null ? { subject: content.subject } : {}), parts };
}
export interface PostInternalInput {
source: TemplateSource;
recipientUserId: string;
vars?: Record<string, unknown>;
locale?: string;
idempotencyKey: string;
}
export interface SendExternalInput {
source: TemplateSource;
target: string; // email address (external)
vars?: Record<string, unknown>;
locale?: string;
idempotencyKey: string;
purpose?: string;
/** The registered recipient to mirror to; omit for a pre-registration send (email only, no mirror). */
mirrorToUserId?: string;
}
/**
* Mail orchestration: render a template, then deliver it.
* - INTERNAL (app-to-app): create an EMAIL interaction on a per-email thread — no SMTP.
* - EXTERNAL: send via SMTP (TemplatedSender) AND mirror a copy into the recipient's in-app inbox,
* but only when they are a registered user (pre-registration sends have no inbox yet).
*
* ingest() writes the interaction + thread but adds no participants, and a thread is only visible to
* its participants — so after each ingest we add BOTH the sender and the recipient as participants.
*/
@Injectable()
export class MailService {
constructor(
private readonly templates: TemplateService,
private readonly ingest: IngestService,
private readonly sender: TemplatedSender,
private readonly actors: ActorResolver,
) {}
async postInternal(principal: MessagePrincipal, input: PostInternalInput): Promise<{ threadId: string; interactionId: string }> {
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'INTERNAL', ...(input.locale ? { locale: input.locale } : {}) });
return this.deposit(principal, input.recipientUserId, content, input.idempotencyKey, 'PORTAL');
}
async sendExternalWithMirror(principal: MessagePrincipal, input: SendExternalInput): Promise<{ commandId: string; mirror?: { threadId: string; interactionId: string } }> {
const command = await this.sender.sendTemplated({
source: input.source, channel: 'EMAIL', target: input.target, vars: input.vars ?? {},
...(input.locale ? { locale: input.locale } : {}), idempotencyKey: input.idempotencyKey, ...(input.purpose ? { purpose: input.purpose } : {}),
});
// Mirror only for a registered recipient (timing rule: no inbox exists pre-registration).
if (!input.mirrorToUserId) return { commandId: command.id };
const { content } = await this.templates.render(input.source, input.vars ?? {}, { channel: 'EMAIL', ...(input.locale ? { locale: input.locale } : {}) });
const mirror = await this.deposit(principal, input.mirrorToUserId, content, `mirror:${input.idempotencyKey}`, 'EMAIL');
return { commandId: command.id, mirror };
}
/** Ingest the rendered content as an EMAIL interaction on a per-email thread, visible to both parties. */
private async deposit(principal: MessagePrincipal, recipientUserId: string, content: RenderedContent, key: string, channelType: string): Promise<{ threadId: string; interactionId: string }> {
const { subject, parts } = contentToParts(content);
const res = await this.ingest.ingest(
{
scope: { orgId: principal.orgId, appId: principal.appId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}) },
channel: { type: channelType, externalChannelId: channelType.toLowerCase() },
source: { handleKind: 'PORTAL_USER', externalId: principal.userId, ...(principal.displayName ? { displayName: principal.displayName } : {}) },
kind: 'EMAIL',
thread: { externalThreadId: key, ...(subject ? { subject } : {}) },
parts,
occurredAt: new Date().toISOString(),
providerEventId: key,
},
key,
);
// Make the thread visible to both the sender and the recipient (ingest adds no participants).
const scope = await this.actors.resolveScope(principal);
const senderActor = await this.actors.resolveActor(scope.id, principal);
const recipientActor = await this.actors.resolveActor(scope.id, {
userId: recipientUserId, appId: principal.appId, orgId: principal.orgId, ...(principal.tenantId ? { tenantId: principal.tenantId } : {}),
});
await this.actors.ensureParticipant(res.threadId, senderActor.id);
await this.actors.ensureParticipant(res.threadId, recipientActor.id);
return { threadId: res.threadId, interactionId: res.interactionId };
}
}