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); } externalEventId(payload: unknown): string | undefined { const id = (payload as EmailInboundPayload)?.messageId; return typeof id === 'string' ? id : 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); } }