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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string | undefined>, 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<SendResult> {
|
||||||
|
return SandboxSink.simulate(cmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { hmacSign } from './hmac';
|
import { hmacSign } from './hmac';
|
||||||
import type { WebhookPayload } from './webhook-adapter';
|
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 = {
|
export const emailFixture: WebhookPayload = {
|
||||||
eventId: 'email-1',
|
eventId: 'email-1',
|
||||||
from: 'someone@example.com',
|
from: 'someone@example.com',
|
||||||
@@ -11,6 +12,30 @@ export const emailFixture: WebhookPayload = {
|
|||||||
text: 'Hello, I have a question.',
|
text: 'Hello, I have a question.',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A first inbound email (starts a thread) for the EmailAdapter. */
|
||||||
|
export const emailInboundFixture: EmailInboundPayload = {
|
||||||
|
messageId: '<m1@example.com>',
|
||||||
|
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: '<m2@example.com>',
|
||||||
|
from: 'buyer@example.com',
|
||||||
|
fromName: 'Buyer',
|
||||||
|
to: ['support@tower.example'],
|
||||||
|
subject: 'Re: Need help with my order',
|
||||||
|
text: 'Any update on this?',
|
||||||
|
inReplyTo: '<m1@example.com>',
|
||||||
|
references: ['<m1@example.com>'],
|
||||||
|
occurredAt: '2026-07-03T11:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
/** WhatsApp-shaped provider payload. */
|
/** WhatsApp-shaped provider payload. */
|
||||||
export const whatsappFixture: WebhookPayload = {
|
export const whatsappFixture: WebhookPayload = {
|
||||||
eventId: 'wa-1',
|
eventId: 'wa-1',
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export * from './types';
|
|||||||
export { hmacSign, hmacVerify } from './hmac';
|
export { hmacSign, hmacVerify } from './hmac';
|
||||||
export { SandboxSink } from './sandbox';
|
export { SandboxSink } from './sandbox';
|
||||||
export { WebhookAdapter, type WebhookPayload } from './webhook-adapter';
|
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';
|
||||||
|
|||||||
Reference in New Issue
Block a user