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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user