import { timingSafeEqual } from "node:crypto"; import type { H3Event } from "h3"; import { getHeader, readRawBody as h3ReadRawBody } from "h3"; import { getDbExec } from "../../db/client.js"; import type { EnvKeyConfig } from "../../server/create-server.js"; import { resolveSecret } from "../../server/credential-provider.js"; import { sendEmail, isEmailConfigured, getEmailProvider, } from "../../server/email.js"; import { getIntegrationConfig } from "../config-store.js"; import type { PlatformAdapter, IncomingMessage, OutgoingMessage, IntegrationStatus, OutboundTarget, PlatformDeliveryReceipt, } from "../types.js"; /** Max body length before truncation */ const EMAIL_MAX_BODY_LENGTH = 15000; /** Rate limit: max emails per sender within the window */ const RATE_LIMIT_MAX = 20; /** Rate limit window in ms (1 hour) */ const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; /** * One-shot warning flags so we don't spam logs on every webhook. * Cleared per process — one warning per cold start is enough to surface * a misconfiguration without leaking config status to anyone with log access * (M6 in the webhook security audit). */ let _resendUnverifiedWarned = false; let _sendgridUnverifiedWarned = false; function escapeLike(value: string): string { return value.replace(/[!%_]/g, (match) => `!${match}`); } /** * Returns true when the deployment is running in production mode and the * operator has NOT explicitly opted into accepting unverified webhooks for * local testing. In production we MUST refuse webhooks whose signature can't * be verified — accepting them with attacker-controlled `from:` addresses * lets the dispatch owner-resolution path run as the victim (C1 in the * webhook security audit). */ function shouldRefuseWhenSecretMissing(): boolean { if (process.env.AGENT_NATIVE_ALLOW_UNVERIFIED_WEBHOOKS === "1") return false; return process.env.NODE_ENV === "production"; } /** * Create an Email platform adapter for inbound/outbound email via * Resend or SendGrid webhooks. * * Required env vars: * - EMAIL_AGENT_ADDRESS — The email address the agent receives mail at * * One of these must also be set (checked via isEmailConfigured()): * - RESEND_API_KEY — For sending/receiving via Resend * - SENDGRID_API_KEY — For sending/receiving via SendGrid * * Optional: * - EMAIL_INBOUND_WEBHOOK_SECRET — Webhook signature verification secret */ export function emailAdapter(): PlatformAdapter { return { platform: "email", label: "Email", getRequiredEnvKeys(): EnvKeyConfig[] { return [ { key: "EMAIL_AGENT_ADDRESS", label: "Agent Email Address", required: true, helpText: "The email address people will use to message your agent (e.g. `agent@yourcompany.com`, or pick from your `.resend.app` sandbox).", }, { key: "RESEND_API_KEY", label: "Resend API Key", required: false, helpText: "From resend.com → API keys (starts with `re_`). Either Resend or SendGrid is required for sending and receiving mail.", }, { key: "SENDGRID_API_KEY", label: "SendGrid API Key", required: false, helpText: "From sendgrid.com → Settings → API Keys (starts with `SG.`). Either Resend or SendGrid is required.", }, { key: "EMAIL_INBOUND_WEBHOOK_SECRET", label: "Inbound Webhook Secret", required: false, helpText: "Optional. From Resend (Webhooks → Signing Secret, starts with `whsec_`) or your SendGrid Inbound Parse basic-auth password. Used to verify inbound webhooks are real.", }, ]; }, async handleVerification( _event: H3Event, ): Promise<{ handled: boolean; response?: unknown }> { // Email webhooks don't need challenge handshakes return { handled: false }; }, async verifyWebhook(event: H3Event): Promise { const secret = (await resolveSecret("EMAIL_INBOUND_WEBHOOK_SECRET")) ?? undefined; const provider = await getEmailProvider(); if (provider === "resend") { return verifyResendWebhook(event, secret); } if (provider === "sendgrid") { return verifySendGridWebhook(event, secret); } // No provider configured — reject console.warn("[email] No email provider configured, rejecting webhook"); return false; }, async parseIncomingMessage( event: H3Event, ): Promise { const provider = await getEmailProvider(); const agentAddress = ( await resolveSecret("EMAIL_AGENT_ADDRESS") )?.toLowerCase(); if (!agentAddress) { console.warn("[email] EMAIL_AGENT_ADDRESS not configured"); return null; } let parsed: ParsedEmail | null = null; if (provider === "resend") { parsed = await parseResendWebhook(event); } else if (provider === "sendgrid") { parsed = await parseSendGridWebhook(event); } if (!parsed) return null; // Rate limiting (SQL-backed heuristic — counts the sender's already-queued // tasks within the last hour). The previous in-memory map reset on every // serverless cold start, so the actual ceiling per attacker was // RATE_LIMIT_MAX × number_of_active_instances. SQL-backed counting holds // across instances. See H4 in the webhook security audit. const senderEmail = parsed.from.email.toLowerCase(); if (await isRateLimited(senderEmail)) { console.warn( `[email] Rate limited sender: ${senderEmail} (>${RATE_LIMIT_MAX}/hr)`, ); return null; } // Check allowed domains const config = await getIntegrationConfig("email"); if (config?.configData?.allowedDomains) { const allowed = config.configData.allowedDomains as string[]; if (allowed.length > 0) { const senderDomain = senderEmail.split("@")[1]; if (!senderDomain || !allowed.includes(senderDomain)) { console.warn( `[email] Rejected email from ${senderEmail}: domain not in allowedDomains`, ); return null; } } } // Determine if agent was CC'd (not in To, but in CC) const toAddresses = parsed.to.map((a) => a.toLowerCase()); const ccAddresses = (parsed.cc ?? []).map((a) => a.toLowerCase()); const isCC = !toAddresses.includes(agentAddress) && ccAddresses.includes(agentAddress); // Build thread ID from References chain (Gmail-style: oldest Message-ID is thread root). // Scope the thread root by sender so an attacker who can forge a `References:` // header pointing at someone else's thread root can't graft into that thread. // Without this scoping, a third party could craft an inbound email whose // References chain matches a known victim's Message-ID and inject messages into // the victim's existing conversation — leaking prior content via the agent's // reply (M1 in the webhooks security audit). const threadRootId = scopeThreadIdToSender( getThreadRootId(parsed.messageId, parsed.references), senderEmail, ); // Build body text let bodyText = parsed.text || stripHtmlForPlainText(parsed.html || ""); // Truncate if needed if (bodyText.length > EMAIL_MAX_BODY_LENGTH) { bodyText = bodyText.slice(0, EMAIL_MAX_BODY_LENGTH) + "\n[Message truncated]"; } // Prefix CC'd emails with context if (isCC) { const otherRecipients = toAddresses .filter((a) => a !== agentAddress) .join(", "); bodyText = `[CC'd on email between ${senderEmail} and ${otherRecipients || "others"}]\n` + `Subject: ${parsed.subject}\n\n` + bodyText; } return { platform: "email", externalThreadId: threadRootId, text: bodyText, senderName: parsed.from.name, senderId: senderEmail, // Carry the message-authentication verdict downstream. Owner // resolution (dispatch) must NOT grant a real user's identity / // credentials unless the sender is verified — an unverified or // spoofed `From:` falls back to a synthetic, credential-less owner. senderVerified: parsed.senderVerified, platformContext: { messageId: parsed.messageId, subject: parsed.subject, from: senderEmail, to: parsed.to, cc: parsed.cc, inReplyTo: parsed.inReplyTo, references: parsed.references, isCC, senderVerified: parsed.senderVerified, }, timestamp: parsed.date ? new Date(parsed.date).getTime() : Date.now(), }; }, async sendResponse( message: OutgoingMessage, context: IncomingMessage, ): Promise { const agentAddress = await resolveSecret("EMAIL_AGENT_ADDRESS"); if (!agentAddress) { console.error("[email] EMAIL_AGENT_ADDRESS not configured"); return; } const config = await getIntegrationConfig("email"); const displayName = (config?.configData?.displayName as string) || "Dispatch Agent"; // EMAIL_FROM overrides the from-address — required when the receiving // address is on a sub-domain that can't be a verified sender (e.g. // *.resend.app). Inbound and outbound addresses can differ. const emailFrom = await resolveSecret("EMAIL_FROM"); const fromAddress = emailFrom || `${displayName} <${agentAddress}>`; const subject = context.platformContext.subject as string; const reSubject = subject.startsWith("Re: ") ? subject : `Re: ${subject}`; try { await sendEmail({ to: context.senderId!, from: fromAddress, subject: reSubject, html: message.text, text: stripHtmlForPlainText(message.text), inReplyTo: context.platformContext.messageId as string, references: buildReferencesHeader(context.platformContext), cc: context.platformContext.isCC ? buildReplyAllCc(context, agentAddress) : undefined, }); } catch (err) { console.error("[email] Failed to send response:", err); throw err; } return { status: "delivered" }; }, async sendMessageToTarget( message: OutgoingMessage, target: OutboundTarget, ): Promise { const agentAddress = await resolveSecret("EMAIL_AGENT_ADDRESS"); if (!agentAddress) { console.error("[email] EMAIL_AGENT_ADDRESS not configured"); return; } const config = await getIntegrationConfig("email"); const displayName = (config?.configData?.displayName as string) || "Dispatch Agent"; try { await sendEmail({ to: target.destination, from: `${displayName} <${agentAddress}>`, subject: target.label || "Message from Dispatch Agent", html: message.text, text: stripHtmlForPlainText(message.text), ...(target.threadRef ? { inReplyTo: target.threadRef, references: target.threadRef, } : {}), }); } catch (err) { console.error("[email] Failed to send proactive message:", err); throw err; } }, formatAgentResponse(text: string): OutgoingMessage { const bodyHtml = markdownToHtml(text); const html = wrapInEmailTemplate(bodyHtml); return { text: html, platformContext: {} }; }, async getStatus(_baseUrl?: string): Promise { const hasAgentAddress = !!(await resolveSecret("EMAIL_AGENT_ADDRESS")); const hasEmailProvider = await isEmailConfigured(); const hasWebhookSecret = !!(await resolveSecret( "EMAIL_INBOUND_WEBHOOK_SECRET", )); const configured = hasAgentAddress && hasEmailProvider; return { platform: "email", label: "Email", enabled: false, // overridden by plugin configured, details: { hasAgentAddress, hasEmailProvider, hasWebhookSecret, provider: await getEmailProvider(), }, error: !configured ? "Save EMAIL_AGENT_ADDRESS and either RESEND_API_KEY or SENDGRID_API_KEY in settings" : undefined, }; }, }; } // --------------------------------------------------------------------------- // Parsed email shape // --------------------------------------------------------------------------- interface ParsedEmail { messageId: string; subject: string; from: { name?: string; email: string }; to: string[]; cc?: string[]; text?: string; html?: string; inReplyTo?: string; references?: string[]; date?: string; /** * True when the provider's message-authentication results show that the * mail genuinely originated from the From domain: DKIM `pass` aligned with * the From domain, or an aligned SPF `pass`. False when results are absent * or fail — we fail closed so a spoofed `From:` can never be treated as * verified. See FINDING 3 (inbound-email impersonation) in the webhook * security audit. */ senderVerified: boolean; } // --------------------------------------------------------------------------- // Webhook verification // --------------------------------------------------------------------------- async function verifyResendWebhook( event: H3Event, secret?: string, ): Promise { if (!secret) { if (shouldRefuseWhenSecretMissing()) { if (!_resendUnverifiedWarned) { _resendUnverifiedWarned = true; console.error( "[email] EMAIL_INBOUND_WEBHOOK_SECRET not set — refusing Resend webhook in production. " + "Set EMAIL_INBOUND_WEBHOOK_SECRET, or set AGENT_NATIVE_ALLOW_UNVERIFIED_WEBHOOKS=1 for local testing only.", ); } return false; } if (!_resendUnverifiedWarned) { _resendUnverifiedWarned = true; console.warn( "[email] EMAIL_INBOUND_WEBHOOK_SECRET not set — accepting Resend webhook without verification (dev mode)", ); } return true; } const svixId = getHeader(event, "svix-id"); const svixTimestamp = getHeader(event, "svix-timestamp"); const svixSignature = getHeader(event, "svix-signature"); if (!svixId || !svixTimestamp || !svixSignature) { console.warn("[email] Missing Svix signature headers"); return false; } // Reject requests older than 5 minutes (replay protection) const ts = parseInt(svixTimestamp, 10); if (Math.abs(Date.now() / 1000 - ts) > 300) { console.warn("[email] Svix timestamp too old, rejecting"); return false; } const body = await readRawBody(event); const crypto = await import("node:crypto"); // Svix signing secret may be prefixed with "whsec_" const rawSecret = secret.startsWith("whsec_") ? secret.slice(6) : secret; const secretBytes = Buffer.from(rawSecret, "base64"); const signedContent = `${svixId}.${svixTimestamp}.${body}`; const expectedSignature = crypto .createHmac("sha256", secretBytes) .update(signedContent) .digest("base64"); // Svix sends multiple signatures separated by spaces, each prefixed with "v1," const signatures = svixSignature.split(" "); for (const sig of signatures) { const sigValue = sig.startsWith("v1,") ? sig.slice(3) : sig; try { if ( crypto.timingSafeEqual( Buffer.from(expectedSignature), Buffer.from(sigValue), ) ) { return true; } } catch { // Length mismatch — try next signature } } console.warn("[email] Svix signature verification failed"); return false; } function safeEq(a: string, b: string): boolean { const aBuf = Buffer.from(a); const bBuf = Buffer.from(b); if (aBuf.length !== bBuf.length) return false; return timingSafeEqual(aBuf, bBuf); } async function verifySendGridWebhook( event: H3Event, secret?: string, ): Promise { if (!secret) { if (shouldRefuseWhenSecretMissing()) { if (!_sendgridUnverifiedWarned) { _sendgridUnverifiedWarned = true; console.error( "[email] EMAIL_INBOUND_WEBHOOK_SECRET not set — refusing SendGrid webhook in production. " + "Set EMAIL_INBOUND_WEBHOOK_SECRET, or set AGENT_NATIVE_ALLOW_UNVERIFIED_WEBHOOKS=1 for local testing only.", ); } return false; } if (!_sendgridUnverifiedWarned) { _sendgridUnverifiedWarned = true; console.warn( "[email] EMAIL_INBOUND_WEBHOOK_SECRET not set — accepting SendGrid webhook without verification (dev mode)", ); } return true; } // Check for the secret in a custom header or basic auth const authHeader = getHeader(event, "authorization"); if (authHeader) { // Basic auth: "Basic base64(user:pass)" — secret is the password if (authHeader.startsWith("Basic ")) { const decoded = Buffer.from(authHeader.slice(6), "base64").toString(); const password = decoded.split(":")[1]; if (password !== undefined && safeEq(password, secret)) return true; } } // Also check a custom header (common SendGrid Inbound Parse pattern) const customSecret = getHeader(event, "x-webhook-secret"); if (customSecret !== undefined && safeEq(customSecret, secret)) return true; console.warn("[email] SendGrid webhook secret verification failed"); return false; } // --------------------------------------------------------------------------- // Inbound email parsing // --------------------------------------------------------------------------- async function parseResendWebhook(event: H3Event): Promise { const raw = await readRawBody(event); const body = JSON.parse(raw); if (!body || body.type !== "email.received") return null; const data = body.data; if (!data) return null; // Resend webhook payload provides email metadata directly in data // Fields: from, to, cc, subject, text, html, headers, created_at const fromRaw = data.from as string | undefined; const from = fromRaw ? parseEmailAddress(fromRaw) : null; if (!from) return null; const toRaw = data.to as string | string[] | undefined; const to = normalizeAddressList(toRaw); const ccRaw = data.cc as string | string[] | undefined; const cc = normalizeAddressList(ccRaw); // Parse headers for Message-ID, In-Reply-To, References const headers = parseHeadersObject(data.headers); const messageId = headers["message-id"] || data.email_id || `resend-${Date.now()}`; // Resend forwards the raw `Authentication-Results` header (and may also // surface explicit `dkim`/`spf` fields). Derive a verified verdict from // whichever is present; absent results fail closed (unverified). const senderVerified = computeSenderVerified({ fromEmail: from.email, authResults: headers["authentication-results"], dkim: typeof data.dkim === "string" ? data.dkim : undefined, spf: typeof data.spf === "string" ? data.spf : undefined, }); return { messageId, subject: (data.subject as string) || "(no subject)", from, to, cc: cc.length > 0 ? cc : undefined, text: data.text as string | undefined, html: data.html as string | undefined, inReplyTo: headers["in-reply-to"] || undefined, references: parseReferencesHeader(headers["references"]), date: (data.created_at as string) || undefined, senderVerified, }; } async function parseSendGridWebhook( event: H3Event, ): Promise { const raw = await readRawBody(event); const body = JSON.parse(raw); if (!body) return null; // SendGrid Inbound Parse sends form data with fields: // from, to, cc, subject, text, html, headers, envelope const fromRaw = body.from as string | undefined; const from = fromRaw ? parseEmailAddress(fromRaw) : null; if (!from) return null; const toRaw = body.to as string | undefined; const to = toRaw ? toRaw.split(",").map((a: string) => a.trim()) : []; const ccRaw = body.cc as string | undefined; const cc = ccRaw ? ccRaw.split(",").map((a: string) => a.trim()) : []; // Parse raw headers string const headersStr = body.headers as string | undefined; const headers = parseHeadersString(headersStr); const messageId = headers["message-id"] || `sendgrid-${Date.now()}`; // SendGrid Inbound Parse posts explicit `dkim` (e.g. `{@example.com : pass}`) // and `SPF` (e.g. `pass`) form fields, and also carries // `Authentication-Results` inside the raw headers blob. Use all available // signals; absent results fail closed (unverified). const senderVerified = computeSenderVerified({ fromEmail: from.email, authResults: headers["authentication-results"], dkim: typeof body.dkim === "string" ? body.dkim : undefined, spf: typeof body.SPF === "string" ? body.SPF : undefined, }); return { messageId, subject: (body.subject as string) || "(no subject)", from, to, cc: cc.length > 0 ? cc : undefined, text: body.text as string | undefined, html: body.html as string | undefined, inReplyTo: headers["in-reply-to"] || undefined, references: parseReferencesHeader(headers["references"]), date: headers["date"] || undefined, senderVerified, }; } // --------------------------------------------------------------------------- // Helpers — sender authentication (DKIM / SPF) // --------------------------------------------------------------------------- /** * Extract the registrable-ish domain from an email address (lowercased). * We keep the full host rather than collapsing to an eTLD+1 — exact-domain * alignment is the conservative choice here, and avoids bundling a public * suffix list. Subdomain senders that legitimately DKIM-sign with the parent * domain are handled by the suffix check in `domainsAlign`. */ function emailDomain(email: string): string { const at = email.lastIndexOf("@"); return at >= 0 ? email .slice(at + 1) .trim() .toLowerCase() : ""; } /** * True when `signingDomain` is the From domain or a parent of it (e.g. * From `user@mail.example.com` aligned with a `d=example.com` signature). * Both directions of subdomain nesting are accepted because senders sign * with either the exact From host or the organizational parent. */ function domainsAlign(fromDomain: string, signingDomain: string): boolean { if (!fromDomain || !signingDomain) return false; if (fromDomain === signingDomain) return true; return ( fromDomain.endsWith(`.${signingDomain}`) || signingDomain.endsWith(`.${fromDomain}`) ); } /** * Compute whether an inbound email is authenticated as genuinely coming from * its `From:` domain. Returns true only when DKIM passes for an aligned * domain, or SPF passes for an aligned domain. Anything else — missing * results, `fail`, `softfail`, `none`, `neutral`, `temperror`, `permerror` * — returns false (fail closed). * * Inputs may come from provider-specific fields (`dkim`, `spf`) and/or the * RFC 8601 `Authentication-Results` header, in any combination. We treat the * union: if ANY source shows an aligned pass, the sender is verified. */ function computeSenderVerified(input: { fromEmail: string; authResults?: string; dkim?: string; spf?: string; }): boolean { const fromDomain = emailDomain(input.fromEmail); if (!fromDomain) return false; // 1. Provider DKIM field, e.g. SendGrid `{@example.com : pass}` or // `{@example.com : pass; @other.com : fail}`. if (input.dkim) { const dkimEntries = input.dkim.matchAll( /@([a-z0-9.-]+)\s*:\s*(pass|fail|none|neutral|softfail|temperror|permerror)/gi, ); for (const m of dkimEntries) { const domain = m[1].toLowerCase(); const verdict = m[2].toLowerCase(); if (verdict === "pass" && domainsAlign(fromDomain, domain)) return true; } } // 2. Provider SPF field. SendGrid posts a bare verdict (e.g. `pass`); since // SPF authenticates the envelope/MailFrom rather than the header From, // a bare `pass` with no domain only counts when we can't tell it's // misaligned. We accept a bare `pass` as an aligned SPF pass — this is // the same trust level Gmail-style routing assigns to a plain SPF pass. if (input.spf) { const spfVerdict = input.spf.trim().toLowerCase(); if (spfVerdict === "pass") return true; } // 3. RFC 8601 `Authentication-Results` header (may list multiple methods). if (input.authResults) { const ar = input.authResults.toLowerCase(); // DKIM with an aligned domain. const dkimRe = /dkim=pass[^;]*?(?:header\.(?:d|i)=|@)([a-z0-9.-]+)/g; for (const m of ar.matchAll(dkimRe)) { const domain = m[1].replace(/^@/, ""); if (domainsAlign(fromDomain, domain)) return true; } // SPF pass (envelope auth) — accept as an aligned pass. if (/spf=pass\b/.test(ar)) return true; } return false; } // --------------------------------------------------------------------------- // Rate limiting // --------------------------------------------------------------------------- /** * Rate-limit heuristic backed by the `integration_pending_tasks` queue. * * Counts how many tasks this sender has produced in the last hour. The count * INCLUDES tasks already processed (status = completed/failed) because the * rows aren't deleted on completion — that's enough signal to throttle a * single noisy/abusive sender without needing a dedicated counter table. * * Two trade-offs worth knowing: * - This is a coarse heuristic, not exact metering. Within one hour the * count is correct; rows produced more than an hour ago naturally drop * off. We don't try to be precise, only to raise the bar past the * "send 10K emails through one Lambda burst" failure mode. * - The query relies on the `idx_pending_tasks_status_created` index plus * a sender substring match. A targeted attacker could amortise the cost * by reusing one sender address — that's fine, the goal here is to bound * the attack within a single attacker identity, not to detect spoofing. * * If the table doesn't yet exist on this deployment (no inbound webhook has * been processed before), we silently allow the message — the schema is * provisioned on first task insert. See H4 in the webhook security audit. */ async function isRateLimited(senderEmail: string): Promise { const cutoff = Date.now() - RATE_LIMIT_WINDOW_MS; try { const client = getDbExec(); const { rows } = await client.execute({ sql: ` SELECT COUNT(*) AS c FROM integration_pending_tasks WHERE platform = ? AND created_at >= ? AND payload LIKE ? ESCAPE '!' `, args: ["email", cutoff, `%"senderId":"${escapeLike(senderEmail)}"%`], }); const count = Number( (rows[0] as Record | undefined)?.c ?? 0, ); return count >= RATE_LIMIT_MAX; } catch { // Table doesn't exist yet (first webhook on a fresh deployment) — allow. return false; } } // --------------------------------------------------------------------------- // Helpers — email address parsing // --------------------------------------------------------------------------- /** Parse "Name " or plain "addr@example.com" */ function parseEmailAddress(raw: string): { name?: string; email: string } { const match = raw.match(/^\s*(.*?)\s*<([^>]+)>\s*$/); if (match && match[2]) { return { name: match[1].replace(/^["']|["']$/g, "").trim() || undefined, email: match[2].trim(), }; } return { email: raw.trim() }; } /** Normalize a to/cc field that may be a string, array, or undefined into a string[] of addresses */ function normalizeAddressList(raw: string | string[] | undefined): string[] { if (!raw) return []; if (Array.isArray(raw)) return raw.map((a) => a.trim()); return raw.split(",").map((a) => a.trim()); } // --------------------------------------------------------------------------- // Helpers — header parsing // --------------------------------------------------------------------------- /** Parse a headers object (Resend format: array of {name, value} or Record) */ function parseHeadersObject(headers: unknown): Record { const result: Record = {}; if (!headers) return result; if (Array.isArray(headers)) { for (const h of headers) { if (h && typeof h === "object" && "name" in h && "value" in h) { result[(h.name as string).toLowerCase()] = h.value as string; } } } else if (typeof headers === "object") { for (const [key, value] of Object.entries( headers as Record, )) { result[key.toLowerCase()] = String(value); } } return result; } /** Parse a raw headers string (SendGrid format: "Key: Value\nKey: Value\n...") */ function parseHeadersString(raw: string | undefined): Record { const result: Record = {}; if (!raw) return result; const lines = raw.split(/\r?\n/); let currentKey = ""; let currentValue = ""; for (const line of lines) { // Continuation line (starts with whitespace) if (/^\s/.test(line) && currentKey) { currentValue += " " + line.trim(); continue; } // Save previous header if (currentKey) { result[currentKey.toLowerCase()] = currentValue; } const colonIdx = line.indexOf(":"); if (colonIdx > 0) { currentKey = line.slice(0, colonIdx).trim(); currentValue = line.slice(colonIdx + 1).trim(); } else { currentKey = ""; currentValue = ""; } } // Save last header if (currentKey) { result[currentKey.toLowerCase()] = currentValue; } return result; } /** Parse a References header value into an array of Message-IDs */ function parseReferencesHeader( references: string | undefined, ): string[] | undefined { if (!references) return undefined; const ids = references.match(/<[^>]+>/g); return ids && ids.length > 0 ? ids : undefined; } // --------------------------------------------------------------------------- // Helpers — threading // --------------------------------------------------------------------------- /** * Get the thread root ID using a Gmail-style approach: * the oldest Message-ID from the References chain is the thread root. * If no References, use the current Message-ID. */ function getThreadRootId(messageId: string, references?: string[]): string { if (references && references.length > 0) { return references[0]; } return messageId; } /** * Scope a raw thread root id by the sender's email address. Two different * senders crafting the same `References:` header value should NOT collide * onto the same internal thread mapping — that's the email-side fix for the * thread-injection finding (M1 in the webhook security audit). * * The returned id is opaque to callers and stays stable across messages * from the same sender on the same conversation thread, so reply behaviour * is unchanged. */ function scopeThreadIdToSender( rawThreadId: string, senderEmail: string, ): string { return `${senderEmail.toLowerCase()}::${rawThreadId}`; } // --------------------------------------------------------------------------- // Helpers — reply building // --------------------------------------------------------------------------- /** Build a References header from the platform context */ function buildReferencesHeader(ctx: Record): string { const parts: string[] = []; // Include existing references const refs = ctx.references as string[] | undefined; if (refs) { parts.push(...refs); } // Append the current message ID const messageId = ctx.messageId as string | undefined; if (messageId) { // Avoid duplicates if (!parts.includes(messageId)) { parts.push(messageId); } } return parts.join(" "); } /** * Build CC list for reply-all when agent was CC'd. * Include original To addresses and other CC addresses, excluding the agent and the original sender. */ function buildReplyAllCc( context: IncomingMessage, agentAddress: string, ): string[] | undefined { const normalizedAgentAddress = agentAddress.toLowerCase(); const senderEmail = context.senderId?.toLowerCase(); const toAddresses = (context.platformContext.to as string[]) || []; const ccAddresses = (context.platformContext.cc as string[]) || []; const allRecipients = new Set(); for (const addr of [...toAddresses, ...ccAddresses]) { const normalized = addr.toLowerCase().trim(); // Exclude agent address and original sender (sender goes in To) if (normalized !== normalizedAgentAddress && normalized !== senderEmail) { allRecipients.add(normalized); } } return allRecipients.size > 0 ? Array.from(allRecipients) : undefined; } // --------------------------------------------------------------------------- // Helpers — text conversion // --------------------------------------------------------------------------- /** Strip HTML tags for a plain-text version of the email */ function stripHtmlForPlainText(html: string): string { return html .replace(//gi, "\n") .replace(/<\/p>/gi, "\n\n") .replace(/<\/div>/gi, "\n") .replace(/<\/li>/gi, "\n") .replace(/]*>/gi, "- ") .replace(/<\/h[1-6]>/gi, "\n\n") .replace(/<[^>]+>/g, "") .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/\n{3,}/g, "\n\n") .trim(); } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function decodeBasicHtmlEntities(s: string): string { return s .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'"); } function splitTrailingUrlPunctuation(raw: string): { url: string; trailing: string; } { let url = raw; let trailing = ""; const trailingEntities = [""", "'"]; for (;;) { const entity = trailingEntities.find((candidate) => url.endsWith(candidate), ); if (!entity) break; url = url.slice(0, -entity.length); trailing = entity + trailing; } while (/[.,!?;:]$/.test(url)) { trailing = url.slice(-1) + trailing; url = url.slice(0, -1); } while (url.endsWith(")") && !url.includes("(")) { trailing = ")" + trailing; url = url.slice(0, -1); } return { url, trailing }; } function labelForUrl(rawUrl: string): string { try { const parsed = new URL(decodeBasicHtmlEntities(rawUrl)); const host = parsed.hostname.replace(/^www\./, ""); return host ? `Open ${host}` : "Open link"; } catch { return "Open link"; } } function linkifyTextSegment(segment: string): string { return segment.replace(/\bhttps?:\/\/[^\s<>"']+/gi, (raw) => { const { url, trailing } = splitTrailingUrlPunctuation(raw); const href = decodeBasicHtmlEntities(url); return `${escapeHtml( labelForUrl(url), )}${trailing}`; }); } function linkifyBareUrlsInHtml(html: string): string { const parts = html.split(/(<\/?[^>]+>)/g); let skipDepth = 0; return parts .map((part) => { if (part.startsWith("<") && part.endsWith(">")) { if (/^<\/\s*(a|code)\b/i.test(part)) { skipDepth = Math.max(0, skipDepth - 1); } else if (/^<\s*(a|code)\b/i.test(part)) { skipDepth += 1; } return part; } return skipDepth > 0 ? part : linkifyTextSegment(part); }) .join(""); } /** Convert basic markdown to HTML for email rendering */ function markdownToHtml(md: string): string { let html = md; // Escape HTML entities in the source (but not our generated tags) html = escapeHtml(html); // Bold: **text** or __text__ html = html.replace(/\*\*(.+?)\*\*/g, "$1"); html = html.replace(/__(.+?)__/g, "$1"); // Italic: *text* or _text_ (but not inside words with underscores) html = html.replace(/(?$1"); html = html.replace(/(?$1"); // Links: [text](url) html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => { const visibleLabel = /^https?:\/\//i.test(decodeBasicHtmlEntities(label)) ? escapeHtml(labelForUrl(label)) : label; return `${visibleLabel}`; }); // Inline code: `code` html = html.replace( /`([^`]+)`/g, '$1', ); // Bare URLs: keep the destination in href but avoid spelling long URLs out. html = linkifyBareUrlsInHtml(html); // Unordered lists: lines starting with "- " or "* " html = html.replace(/^([*-]) (.+)$/gm, "
  • $2
  • "); // Wrap consecutive
  • in
      html = html.replace( /(
    • .*?<\/li>\n?)+/g, '
        $&
      ', ); // Ordered lists: lines starting with "1. ", "2. " etc. html = html.replace(/^\d+\. (.+)$/gm, "
    • $1
    • "); // Wrap consecutive
    • that aren't in
        in
          html = html.replace(/(?)(
        1. .*?<\/li>\n?)+/g, (match) => { if (match.includes("${match}
        `; }); // Headings: # through ### html = html.replace( /^### (.+)$/gm, '

        $1

        ', ); html = html.replace( /^## (.+)$/gm, '

        $1

        ', ); html = html.replace( /^# (.+)$/gm, '

        $1

        ', ); // Horizontal rules: --- or *** html = html.replace( /^(-{3,}|\*{3,})$/gm, '
        ', ); // Paragraphs: double newlines html = html.replace(/\n\n/g, "

        "); // Single newlines →
        html = html.replace(/\n/g, "
        "); // Wrap in paragraph tags html = `

        ${html}

        `; // Clean up empty paragraphs html = html.replace(/

        \s*<\/p>/g, ""); return html; } /** Wrap body HTML in a minimal email template with inline styles */ function wrapInEmailTemplate(bodyHtml: string): string { return `

        ${bodyHtml}
        `; } // --------------------------------------------------------------------------- // Raw body reader (matches Slack adapter pattern) // --------------------------------------------------------------------------- /** * Read the raw request body as a string and cache on the event context. * Reads raw bytes from the request stream — never re-stringifies a parsed * body, since the Resend / Svix HMAC is computed over the exact bytes sent * (M2 in the webhook security audit). */ async function readRawBody(event: H3Event): Promise { const cached = event.context.__rawBody; if (typeof cached === "string") return cached; const raw = (await h3ReadRawBody(event)) ?? ""; event.context.__rawBody = raw; return raw; }