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,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user