diff --git a/packages/iios-adapter-sdk/src/email-adapter.test.ts b/packages/iios-adapter-sdk/src/email-adapter.test.ts new file mode 100644 index 0000000..8521c58 --- /dev/null +++ b/packages/iios-adapter-sdk/src/email-adapter.test.ts @@ -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(''); + expect(req.thread?.externalThreadId).toBe('email:'); + expect(req.thread?.subject).toBe('Need help with my order'); // Re:/Fwd: stripped + expect((req.metadata as { messageId: string }).messageId).toBe(''); + }); + + 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: + expect(reply.providerEventId).toBe(''); // 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:'); // uses inReplyTo + }); + + it('derives text from html when no plain text is present', () => { + const req = adapter.normalize({ messageId: '', from: 'x@y', html: '

Hello there

' }, 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); + }); +}); diff --git a/packages/iios-adapter-sdk/src/email-adapter.ts b/packages/iios-adapter-sdk/src/email-adapter.ts new file mode 100644 index 0000000..99713e0 --- /dev/null +++ b/packages/iios-adapter-sdk/src/email-adapter.ts @@ -0,0 +1,66 @@ +import type { IngestInteractionRequest } from '@insignia/iios-contracts'; +import type { AdapterCapability, ChannelAdapter, NormalizeContext, OutboundCommandInput, SendResult } from './types'; +import { hmacVerify } from './hmac'; +import { SandboxSink } from './sandbox'; + +/** Canonical inbound-email shape. Vendor payloads (SendGrid Inbound Parse, Mailgun + * Routes, Postmark) map into this — provider glue stays a thin transform. */ +export interface EmailInboundPayload { + messageId: string; // RFC 5322 Message-ID → providerEventId (dedup on redelivery) + from: string; + fromName?: string; + to?: string[]; + cc?: string[]; + subject?: string; + text?: string; + html?: string; + inReplyTo?: string; // Message-ID this is a reply to + references?: string[]; // full reference chain, root-first (threading) + occurredAt?: string; + headers?: Record; +} + +const stripReply = (s?: string): string => (s ?? '').replace(/^(re|fwd|fw)\s*:\s*/i, '').trim(); +const stripHtml = (h?: string): string => (h ?? '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + +/** + * Email channel adapter. Turns a provider's inbound email into a normalized + * interaction with email-native threading: the whole reply chain collapses to one + * IIOS thread (rooted at `references[0]`/`inReplyTo`), and `messageId` dedups + * redelivery. HMAC signature for now (per-vendor schemes are a follow-up). + */ +export class EmailAdapter implements ChannelAdapter { + readonly channelType = 'EMAIL'; + readonly capability: AdapterCapability = { + canReceive: true, + canSend: true, + supportsThreads: true, + requiresFollowupFetch: false, + }; + + verifySignature(rawBody: string, headers: Record, secret: string): boolean { + const sig = headers['x-iios-signature'] ?? headers['X-Iios-Signature']; + return hmacVerify(rawBody, secret, typeof sig === 'string' ? sig : undefined); + } + + normalize(payload: unknown, ctx: NormalizeContext): IngestInteractionRequest { + const p = payload as EmailInboundPayload; + // A reply's thread root is the first Message-ID in its chain → same IIOS thread as the original. + const threadRoot = p.references?.[0] ?? p.inReplyTo ?? p.messageId; + return { + scope: ctx.scope, + channel: { type: 'EMAIL', externalChannelId: 'email' }, + source: { handleKind: 'EXTERNAL_VISITOR', externalId: p.from, displayName: p.fromName ?? p.from }, + kind: 'MESSAGE', + thread: { externalThreadId: `email:${threadRoot}`, subject: stripReply(p.subject) }, + parts: [{ kind: 'TEXT', bodyText: p.text ?? stripHtml(p.html), mimeType: 'text/plain' }], + occurredAt: p.occurredAt ?? new Date().toISOString(), + providerEventId: p.messageId, + metadata: { to: p.to, cc: p.cc, messageId: p.messageId, inReplyTo: p.inReplyTo, references: p.references, html: p.html }, + }; + } + + async send(cmd: OutboundCommandInput): Promise { + return SandboxSink.simulate(cmd); + } +} diff --git a/packages/iios-adapter-sdk/src/fixtures.ts b/packages/iios-adapter-sdk/src/fixtures.ts index 0af7a2c..fb2d617 100644 --- a/packages/iios-adapter-sdk/src/fixtures.ts +++ b/packages/iios-adapter-sdk/src/fixtures.ts @@ -1,7 +1,8 @@ import { hmacSign } from './hmac'; import type { WebhookPayload } from './webhook-adapter'; +import type { EmailInboundPayload } from './email-adapter'; -/** Email-shaped provider payload (as the adapter's normalize input). */ +/** Email-shaped WebhookPayload (legacy generic-webhook fixture). */ export const emailFixture: WebhookPayload = { eventId: 'email-1', from: 'someone@example.com', @@ -11,6 +12,30 @@ export const emailFixture: WebhookPayload = { text: 'Hello, I have a question.', }; +/** A first inbound email (starts a thread) for the EmailAdapter. */ +export const emailInboundFixture: EmailInboundPayload = { + messageId: '', + from: 'buyer@example.com', + fromName: 'Buyer', + to: ['support@tower.example'], + subject: 'Need help with my order', + text: 'Hi, my order has not arrived yet.', + occurredAt: '2026-07-03T10:00:00.000Z', +}; + +/** A reply to `emailInboundFixture` — must land on the SAME email thread. */ +export const emailReplyFixture: EmailInboundPayload = { + messageId: '', + from: 'buyer@example.com', + fromName: 'Buyer', + to: ['support@tower.example'], + subject: 'Re: Need help with my order', + text: 'Any update on this?', + inReplyTo: '', + references: [''], + occurredAt: '2026-07-03T11:00:00.000Z', +}; + /** WhatsApp-shaped provider payload. */ export const whatsappFixture: WebhookPayload = { eventId: 'wa-1', diff --git a/packages/iios-adapter-sdk/src/index.ts b/packages/iios-adapter-sdk/src/index.ts index 99714d7..1766d02 100644 --- a/packages/iios-adapter-sdk/src/index.ts +++ b/packages/iios-adapter-sdk/src/index.ts @@ -2,4 +2,5 @@ export * from './types'; export { hmacSign, hmacVerify } from './hmac'; export { SandboxSink } from './sandbox'; export { WebhookAdapter, type WebhookPayload } from './webhook-adapter'; -export { emailFixture, whatsappFixture, signedFixture } from './fixtures'; +export { EmailAdapter, type EmailInboundPayload } from './email-adapter'; +export { emailFixture, whatsappFixture, emailInboundFixture, emailReplyFixture, signedFixture } from './fixtures';