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:
@@ -11,6 +11,7 @@ import { ThreadsModule } from './threads/threads.module';
|
|||||||
import { MessageModule } from './messaging/message.module';
|
import { MessageModule } from './messaging/message.module';
|
||||||
import { InboxModule } from './inbox/inbox.module';
|
import { InboxModule } from './inbox/inbox.module';
|
||||||
import { TemplateModule } from './templates/template.module';
|
import { TemplateModule } from './templates/template.module';
|
||||||
|
import { MailModule } from './mail/mail.module';
|
||||||
import { MediaModule } from './media/media.module';
|
import { MediaModule } from './media/media.module';
|
||||||
import { NotificationModule } from './notifications/notification.module';
|
import { NotificationModule } from './notifications/notification.module';
|
||||||
import { SupportModule } from './support/support.module';
|
import { SupportModule } from './support/support.module';
|
||||||
@@ -39,6 +40,7 @@ import { DevController } from './dev/dev.controller';
|
|||||||
MessageModule,
|
MessageModule,
|
||||||
InboxModule,
|
InboxModule,
|
||||||
TemplateModule,
|
TemplateModule,
|
||||||
|
MailModule,
|
||||||
MediaModule,
|
MediaModule,
|
||||||
NotificationModule,
|
NotificationModule,
|
||||||
SupportModule,
|
SupportModule,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
@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<string, unknown>;
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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: '<p>Hi <b>Dana</b></p>', 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: '<p>h</p>', text: 't' })).toEqual({ subject: 'S', parts: [{ kind: 'HTML', bodyText: '<p>h</p>' }, { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user