Adds adapter-owned externalEventId extraction to the ChannelAdapter contract (WebhookAdapter→eventId, EmailAdapter→Message-ID) and uses it in InboundService, so email replays dedup on Message-ID (previously only payload.eventId worked). Adds an email-dedup spec + smoke-email.mjs proving inbound → normalized, a reply joins the same email thread, bad signature rejected, and outbound EMAIL SENT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,17 @@ describe('Email adapter — inbound + threading', () => {
|
||||
expect(await prisma.iiosThread.count()).toBe(1); // one email conversation
|
||||
});
|
||||
|
||||
it('redelivered email (same Message-ID) → one raw event, one interaction (dedup)', async () => {
|
||||
const svc = inbound();
|
||||
const { body, headers } = signedFixture(emailInboundFixture, SECRET);
|
||||
await svc.receive('EMAIL', body, headers);
|
||||
const second = await svc.receive('EMAIL', body, headers);
|
||||
expect(second.deduped).toBe(true);
|
||||
await normalizeAll();
|
||||
expect(await prisma.iiosInboundRawEvent.count()).toBe(1);
|
||||
expect(await prisma.iiosInteraction.count()).toBe(1);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -28,7 +28,8 @@ export class InboundService {
|
||||
const { adapter, config } = reg;
|
||||
|
||||
const payload = this.safeParse(rawBody);
|
||||
const externalEventId = typeof payload?.eventId === 'string' ? payload.eventId : undefined;
|
||||
// The adapter extracts its provider-native id (eventId, Message-ID, …) for replay dedup.
|
||||
const externalEventId = adapter.externalEventId?.(payload) ?? (typeof payload?.eventId === 'string' ? payload.eventId : undefined);
|
||||
|
||||
// Fail-closed: a bad/absent signature is recorded and rejected — never ingested.
|
||||
if (!adapter.verifySignature(rawBody, headers, config.secret)) {
|
||||
|
||||
Reference in New Issue
Block a user