f0ea1ca553
CI / build (push) Failing after 2m8s
Adds adapter-owned externalEventId extraction to the ChannelAdapter contract (WebhookAdapter→eventId, EmailAdapter→Message-ID) and uses it in InboundService, so email replays dedup on Message-ID (previously only payload.eventId worked). Adds an email-dedup spec + smoke-email.mjs proving inbound → normalized, a reply joins the same email thread, bad signature rejected, and outbound EMAIL SENT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72 lines
3.1 KiB
TypeScript
72 lines
3.1 KiB
TypeScript
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);
|
|
}
|
|
|
|
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<SendResult> {
|
|
return SandboxSink.simulate(cmd);
|
|
}
|
|
}
|