From 9b075f46f9fd0e845e399453171d97757d283b2c Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 13:54:32 +0530 Subject: [PATCH] feat(mail): inbox mirror + INTERNAL (app-to-app) delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/iios-service/src/app.module.ts | 2 + .../iios-service/src/mail/mail.controller.ts | 55 +++++++++ packages/iios-service/src/mail/mail.dto.ts | 30 +++++ packages/iios-service/src/mail/mail.module.ts | 18 +++ .../src/mail/mail.service.spec.ts | 106 ++++++++++++++++++ .../iios-service/src/mail/mail.service.ts | 105 +++++++++++++++++ 6 files changed, 316 insertions(+) create mode 100644 packages/iios-service/src/mail/mail.controller.ts create mode 100644 packages/iios-service/src/mail/mail.dto.ts create mode 100644 packages/iios-service/src/mail/mail.module.ts create mode 100644 packages/iios-service/src/mail/mail.service.spec.ts create mode 100644 packages/iios-service/src/mail/mail.service.ts diff --git a/packages/iios-service/src/app.module.ts b/packages/iios-service/src/app.module.ts index a1345a2..1afdae9 100644 --- a/packages/iios-service/src/app.module.ts +++ b/packages/iios-service/src/app.module.ts @@ -11,6 +11,7 @@ import { ThreadsModule } from './threads/threads.module'; import { MessageModule } from './messaging/message.module'; import { InboxModule } from './inbox/inbox.module'; import { TemplateModule } from './templates/template.module'; +import { MailModule } from './mail/mail.module'; import { MediaModule } from './media/media.module'; import { NotificationModule } from './notifications/notification.module'; import { SupportModule } from './support/support.module'; @@ -39,6 +40,7 @@ import { DevController } from './dev/dev.controller'; MessageModule, InboxModule, TemplateModule, + MailModule, MediaModule, NotificationModule, SupportModule, diff --git a/packages/iios-service/src/mail/mail.controller.ts b/packages/iios-service/src/mail/mail.controller.ts new file mode 100644 index 0000000..968b1ff --- /dev/null +++ b/packages/iios-service/src/mail/mail.controller.ts @@ -0,0 +1,55 @@ +import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common'; +import { SessionVerifier } from '../platform/session.verifier'; +import type { MessagePrincipal } from '../identity/actor.resolver'; +import { MailService } from './mail.service'; +import { MailInternalDto, MailSendDto } from './mail.dto'; +import type { TemplateSource } from '../templates/template.model'; + +@Controller('v1/mail') +export class MailController { + constructor( + private readonly mail: MailService, + private readonly session: SessionVerifier, + ) {} + + /** App-to-app mail — renders a template and posts it into the recipient's in-app inbox (no SMTP). */ + @Post('internal') + async internal(@Body() body: MailInternalDto, @Headers('authorization') authorization?: string) { + const principal = this.principal(authorization); + return this.mail.postInternal(principal, { + source: this.source(body), + recipientUserId: body.recipientUserId, + vars: body.vars ?? {}, + ...(body.locale ? { locale: body.locale } : {}), + idempotencyKey: body.idempotencyKey, + }); + } + + /** External email (SMTP) + an in-app mirror when a registered recipient is named. */ + @Post('send') + async send(@Body() body: MailSendDto, @Headers('authorization') authorization?: string) { + const principal = this.principal(authorization); + return this.mail.sendExternalWithMirror(principal, { + source: this.source(body), + target: body.target, + vars: body.vars ?? {}, + ...(body.locale ? { locale: body.locale } : {}), + idempotencyKey: body.idempotencyKey, + ...(body.purpose ? { purpose: body.purpose } : {}), + ...(body.mirrorToUserId ? { mirrorToUserId: body.mirrorToUserId } : {}), + }); + } + + private source(body: { key?: string; version?: number; inline?: { subject?: string; html?: string; text?: string; variables?: string[] } }): TemplateSource { + if (body.inline && body.key) throw new BadRequestException('provide either "key" or "inline", not both'); + if (body.inline) return { inline: body.inline }; + if (body.key) return body.version != null ? { key: body.key, version: body.version } : { key: body.key }; + throw new BadRequestException('one of "key" or "inline" is required'); + } + + private principal(authorization?: string): MessagePrincipal { + const token = (authorization ?? '').replace(/^Bearer\s+/i, ''); + if (!token) throw new BadRequestException('Authorization bearer token is required'); + return this.session.verify(token); + } +} diff --git a/packages/iios-service/src/mail/mail.dto.ts b/packages/iios-service/src/mail/mail.dto.ts new file mode 100644 index 0000000..92844aa --- /dev/null +++ b/packages/iios-service/src/mail/mail.dto.ts @@ -0,0 +1,30 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { InlineTemplateDto } from '../templates/template.dto'; + +/** POST /v1/mail/internal — app-to-app mail (no SMTP). Provide EITHER `key` OR `inline`. */ +export class MailInternalDto { + @IsOptional() @IsString() @IsNotEmpty() key?: string; + @IsOptional() @IsInt() version?: number; + @IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto; + + @IsString() @IsNotEmpty() recipientUserId!: string; + @IsOptional() @IsObject() vars?: Record; + @IsOptional() @IsString() locale?: string; + @IsString() @IsNotEmpty() idempotencyKey!: string; +} + +/** POST /v1/mail/send — external email via SMTP + optional in-app mirror for a registered recipient. */ +export class MailSendDto { + @IsOptional() @IsString() @IsNotEmpty() key?: string; + @IsOptional() @IsInt() version?: number; + @IsOptional() @ValidateNested() @Type(() => InlineTemplateDto) inline?: InlineTemplateDto; + + @IsString() @IsNotEmpty() target!: string; + @IsOptional() @IsObject() vars?: Record; + @IsOptional() @IsString() locale?: string; + @IsString() @IsNotEmpty() idempotencyKey!: string; + @IsOptional() @IsString() purpose?: string; + /** The registered recipient to mirror to; omit for a pre-registration send (email only). */ + @IsOptional() @IsString() mirrorToUserId?: string; +} diff --git a/packages/iios-service/src/mail/mail.module.ts b/packages/iios-service/src/mail/mail.module.ts new file mode 100644 index 0000000..3ff835d --- /dev/null +++ b/packages/iios-service/src/mail/mail.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TemplateModule } from '../templates/template.module'; +import { InteractionsModule } from '../interactions/interactions.module'; +import { MailService } from './mail.service'; +import { MailController } from './mail.controller'; + +/** + * Mail orchestration: render a template (TemplateModule) → deliver it either app-to-app (an EMAIL + * interaction via IngestService) or externally (TemplatedSender/SMTP) with an in-app mirror. + * SessionVerifier + ActorResolver are global. + */ +@Module({ + imports: [TemplateModule, InteractionsModule], + controllers: [MailController], + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/packages/iios-service/src/mail/mail.service.spec.ts b/packages/iios-service/src/mail/mail.service.spec.ts new file mode 100644 index 0000000..ea1dc08 --- /dev/null +++ b/packages/iios-service/src/mail/mail.service.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { PrismaClient } from '@prisma/client'; +import { makeFakePorts } from '@insignia/iios-testkit'; +import { resetDb } from '../test-utils/reset-db'; +import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; +import { IngestService } from '../interactions/ingest.service'; +import { MessageService } from '../messaging/message.service'; +import { OutboundService } from '../adapters/outbound.service'; +import { CapabilityBroker } from '../capability/capability.broker'; +import { CapabilityProviderRegistry } from '../capability/capability.registry'; +import { IdempotencyService } from '../idempotency/idempotency.service'; +import { TemplateRepository } from '../templates/template.repository'; +import { TemplateService } from '../templates/template.service'; +import { TemplatedSender } from '../templates/templated-sender'; +import { MailService, contentToParts } from './mail.service'; +import type { PrismaService } from '../prisma/prisma.service'; + +const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios_test?schema=public'; +const prisma = new PrismaClient({ datasources: { db: { url } } }); +const asService = prisma as unknown as PrismaService; +const actors = new ActorResolver(asService); + +function mail(): MailService { + const templates = new TemplateService(new TemplateRepository(asService)); + const ingest = new IngestService(asService, makeFakePorts(), actors); + const outbound = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService)); + const sender = new TemplatedSender(templates, outbound, makeFakePorts()); + return new MailService(templates, ingest, sender, actors); +} +const messages = () => new MessageService(asService, makeFakePorts(), actors); + +const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' }; +const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' }; +const inline = { inline: { subject: 'Welcome Dana', html: '

Hi Dana

', text: 'Hi Dana', variables: [] } }; + +beforeAll(async () => { await prisma.$connect(); }); +afterAll(async () => { await prisma.$disconnect(); }); +beforeEach(async () => { await resetDb(prisma); }); + +describe('contentToParts (pure)', () => { + it('produces HTML + TEXT parts and the subject', () => { + expect(contentToParts({ subject: 'S', html: '

h

', text: 't' })).toEqual({ subject: 'S', parts: [{ kind: 'HTML', bodyText: '

h

' }, { kind: 'TEXT', bodyText: 't' }] }); + }); + it('never yields zero parts (empty text fallback)', () => { + expect(contentToParts({ subject: 'S' }).parts).toEqual([{ kind: 'TEXT', bodyText: '' }]); + }); +}); + +describe('MailService.postInternal', () => { + it('creates an EMAIL interaction and makes the thread visible to BOTH sender and recipient', async () => { + const res = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:1' }); + + const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: res.interactionId }, include: { parts: true } }); + expect(interaction.kind).toBe('EMAIL'); + expect(interaction.parts.map((p) => p.kind).sort()).toEqual(['HTML', 'TEXT']); + + const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: res.threadId } }); + expect(thread.subject).toBe('Welcome Dana'); + + // The load-bearing assertion: the RECIPIENT can see the thread in their inbox. + const bobThreads = await messages().listThreads(bob); + expect(bobThreads.map((t) => t.threadId)).toContain(res.threadId); + // ...and so can the sender. + const aliceThreads = await messages().listThreads(alice); + expect(aliceThreads.map((t) => t.threadId)).toContain(res.threadId); + }); + + it('is idempotent per key — a replay reuses the same thread, no duplicate interaction', async () => { + const a = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); + const b = await mail().postInternal(alice, { source: inline, recipientUserId: 'bob', idempotencyKey: 'int:dup' }); + expect(b.threadId).toBe(a.threadId); + expect(await prisma.iiosInteraction.count({ where: { threadId: a.threadId } })).toBe(1); + }); +}); + +describe('MailService.sendExternalWithMirror', () => { + const ext = { source: inline, target: 'dana@acme.com', idempotencyKey: 'recv:1' } as const; + + it('sends via the outbound pipeline AND mirrors into a registered recipient inbox', async () => { + const res = await mail().sendExternalWithMirror(alice, { ...ext, mirrorToUserId: 'bob' }); + // outbound command exists (SENT via sandbox in tests) + const cmd = await prisma.iiosOutboundCommand.findUniqueOrThrow({ where: { id: res.commandId } }); + expect(cmd.channelType).toBe('EMAIL'); + expect(cmd.target).toBe('dana@acme.com'); + // mirror interaction visible to the recipient + expect(res.mirror).toBeDefined(); + const bobThreads = await messages().listThreads(bob); + expect(bobThreads.map((t) => t.threadId)).toContain(res.mirror!.threadId); + }); + + it('does NOT mirror when there is no registered recipient (pre-registration send)', async () => { + const res = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:2' }); // no mirrorToUserId + expect(res.mirror).toBeUndefined(); + // an outbound command was created, but no mirror interaction + expect(await prisma.iiosOutboundCommand.count({ where: { id: res.commandId } })).toBe(1); + expect(await prisma.iiosInteraction.count()).toBe(0); + }); + + it('is idempotent — a replay yields one command and one mirror', async () => { + const a = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' }); + const b = await mail().sendExternalWithMirror(alice, { ...ext, idempotencyKey: 'recv:dup', mirrorToUserId: 'bob' }); + expect(b.commandId).toBe(a.commandId); + expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'recv:dup' } })).toBe(1); + expect(await prisma.iiosInteraction.count({ where: { threadId: a.mirror!.threadId } })).toBe(1); + }); +}); diff --git a/packages/iios-service/src/mail/mail.service.ts b/packages/iios-service/src/mail/mail.service.ts new file mode 100644 index 0000000..7d696f5 --- /dev/null +++ b/packages/iios-service/src/mail/mail.service.ts @@ -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; + locale?: string; + idempotencyKey: string; +} + +export interface SendExternalInput { + source: TemplateSource; + target: string; // email address (external) + vars?: Record; + 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 }; + } +}