feat(iios): register EmailAdapter for EMAIL + email-shaped egress provider
AdapterRegistry now backs the EMAIL channel with the email-native EmailAdapter
(RFC threading) instead of the generic WebhookAdapter. Adds EmailProvider — an
email-envelope egress provider ({to,subject,text,html,inReplyTo}) registered for
EMAIL when IIOS_PROVIDER_URL_EMAIL is set (sandbox stays the default). Spec:
signed email → normalized interaction on the email thread; a reply joins the
same thread (one conversation); bad signature → rejected (fail-closed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import { resetDb } from '../test-utils/reset-db';
|
||||
import { ProjectionCursorService } from '../projection/projection-cursor.service';
|
||||
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
|
||||
import { signedFixture, emailFixture } from '@insignia/iios-adapter-sdk';
|
||||
import { signedFixture, emailFixture, emailInboundFixture, emailReplyFixture } from '@insignia/iios-adapter-sdk';
|
||||
import { AdapterRegistry } from './adapter.registry';
|
||||
import { InboundService } from './inbound.service';
|
||||
import { OutboundService } from './outbound.service';
|
||||
@@ -81,6 +81,46 @@ describe('Adapter inbound (P5)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email adapter — inbound + threading', () => {
|
||||
const receiveEmail = async (payload: object) => {
|
||||
const { body, headers } = signedFixture(payload, SECRET);
|
||||
return inbound().receive('EMAIL', body, headers);
|
||||
};
|
||||
const normalizeAll = async () => {
|
||||
for (const ev of await rawReceivedEvents()) await projector().onRawReceived(ev);
|
||||
};
|
||||
|
||||
it('a signed inbound email → a normalized interaction with the body on the email thread', async () => {
|
||||
const ack = await receiveEmail(emailInboundFixture);
|
||||
expect(ack.status).toBe('RECEIVED');
|
||||
await normalizeAll();
|
||||
|
||||
const raw = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
|
||||
expect(raw.status).toBe('NORMALIZED');
|
||||
const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: raw.interactionId! }, include: { parts: true, thread: true } });
|
||||
expect(interaction.parts[0]?.bodyText).toBe(emailInboundFixture.text);
|
||||
expect(interaction.thread?.externalThreadRef).toBe('email:<m1@example.com>');
|
||||
});
|
||||
|
||||
it('a reply email (In-Reply-To) lands on the SAME email thread as the original', async () => {
|
||||
await receiveEmail(emailInboundFixture);
|
||||
await receiveEmail(emailReplyFixture);
|
||||
await normalizeAll();
|
||||
|
||||
const interactions = await prisma.iiosInteraction.findMany({ orderBy: { occurredAt: 'asc' } });
|
||||
expect(interactions).toHaveLength(2);
|
||||
expect(interactions[0]!.threadId).toBe(interactions[1]!.threadId); // reply joined the thread
|
||||
expect(await prisma.iiosThread.count()).toBe(1); // one email conversation
|
||||
});
|
||||
|
||||
it('bad signature on EMAIL → REJECTED, nothing ingested (fail-closed)', async () => {
|
||||
const { body } = signedFixture(emailInboundFixture, SECRET);
|
||||
await expect(inbound().receive('EMAIL', body, { 'x-iios-signature': 'sha256=bad' })).rejects.toThrow();
|
||||
expect(await prisma.iiosInboundRawEvent.count({ where: { status: 'REJECTED' } })).toBe(1);
|
||||
expect(await prisma.iiosInteraction.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adapter outbound (P5)', () => {
|
||||
const outbound = (limit?: number): OutboundService => {
|
||||
const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
|
||||
|
||||
Reference in New Issue
Block a user