feat(iios): EmailAdapter (email-native normalize + threading) in adapter-sdk
Adds a canonical EmailInboundPayload + EmailAdapter implementing the ChannelAdapter contract: HMAC signature verify + normalize with email-native threading (whole reply chain → one IIOS thread via references/inReplyTo root), messageId as providerEventId for dedup, html→text fallback, Re:/Fwd: stripping. Plus emailInboundFixture/emailReplyFixture and unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EmailAdapter, emailInboundFixture, emailReplyFixture, signedFixture } from './index';
|
||||
|
||||
const ctx = { scope: { orgId: 'o', appId: 'a' } };
|
||||
|
||||
describe('EmailAdapter', () => {
|
||||
const adapter = new EmailAdapter();
|
||||
|
||||
it('normalizes an inbound email into an ingest request', () => {
|
||||
const req = adapter.normalize(emailInboundFixture, ctx);
|
||||
expect(req.channel.type).toBe('EMAIL');
|
||||
expect(req.source.handleKind).toBe('EXTERNAL_VISITOR');
|
||||
expect(req.source.externalId).toBe(emailInboundFixture.from);
|
||||
expect(req.parts[0]?.bodyText).toBe(emailInboundFixture.text);
|
||||
expect(req.providerEventId).toBe('<m1@example.com>');
|
||||
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>');
|
||||
expect(req.thread?.subject).toBe('Need help with my order'); // Re:/Fwd: stripped
|
||||
expect((req.metadata as { messageId: string }).messageId).toBe('<m1@example.com>');
|
||||
});
|
||||
|
||||
it('threads a reply onto the SAME email thread as the original', () => {
|
||||
const first = adapter.normalize(emailInboundFixture, ctx);
|
||||
const reply = adapter.normalize(emailReplyFixture, ctx);
|
||||
expect(reply.thread?.externalThreadId).toBe(first.thread?.externalThreadId); // email:<m1@example.com>
|
||||
expect(reply.providerEventId).toBe('<m2@example.com>'); // but its own dedup id
|
||||
});
|
||||
|
||||
it('falls back to the reply target when there is no references chain', () => {
|
||||
const req = adapter.normalize({ ...emailReplyFixture, references: undefined }, ctx);
|
||||
expect(req.thread?.externalThreadId).toBe('email:<m1@example.com>'); // uses inReplyTo
|
||||
});
|
||||
|
||||
it('derives text from html when no plain text is present', () => {
|
||||
const req = adapter.normalize({ messageId: '<h@x>', from: 'x@y', html: '<p>Hello <b>there</b></p>' }, ctx);
|
||||
expect(req.parts[0]?.bodyText).toBe('Hello there');
|
||||
});
|
||||
|
||||
it('verifies an HMAC-signed body and rejects a bad/absent signature', () => {
|
||||
const secret = 'sekret';
|
||||
const { body, headers } = signedFixture(emailInboundFixture, secret);
|
||||
expect(adapter.verifySignature(body, headers, secret)).toBe(true);
|
||||
expect(adapter.verifySignature(body, { 'x-iios-signature': 'sha256=bad' }, secret)).toBe(false);
|
||||
expect(adapter.verifySignature(body, {}, secret)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user