Ctrl K
ComunidadGuías…

workers/inbound.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Copyright (c) 2026 Doforu
// Copyright (c) 2026 Cloudflare, Inc.
// Licensed under the Apache 2.0 license found in the LICENSE-APACHE file or at:
//     https://opensource.org/licenses/Apache-2.0
// Modifications Copyright (c) 2026 Doforu, distributed under the AGPL-3.0-only (see LICENSE).

/**
 * Resend Inbound Webhook handler.
 *
 * Processes incoming emails forwarded by Resend for domains NOT on
 * Cloudflare DNS (e.g., Vercel). The flow is:
 *   1. Resend sends a webhook POST with metadata (email_id, from, to, etc.)
 *   2. We call Resend's GET /emails/receiving/{email_id} for full content
 *   3. We download attachments from Resend
 *   4. We store everything (metadata + attachments) exactly like receiveEmail()
 */

import type { Env } from "./types";
import { Folders } from "../shared/folders";
import type { StoredAttachment } from "./lib/attachments";
import * as dbService from "./db";
import { fetchWithTimeout } from "./lib/fetch-with-timeout";

// ── Types ──────────────────────────────────────────────────────────

interface ResendAttachment {
	id: string;
	filename: string;
	content_type: string;
	content_disposition: string | null;
	content_id: string | null;
}

export interface ResendInboundPayload {
	type: "email.received";
	created_at: string;
	data: {
		email_id: string;
		created_at: string;
		from: string;
		to: string[];
		bcc: string[];
		cc: string[];
		received_for: string[];
		message_id: string;
		subject: string;
		attachments: ResendAttachment[];
	};
}

interface ResendFullEmail {
	email_id: string;
	created_at: string;
	from: string;
	to: string[];
	bcc: string[];
	cc: string[];
	received_for: string[];
	message_id: string;
	subject: string;
	html: string | null;
	text: string | null;
	headers: Array<{ key: string; value: string }>;
	attachments: Array<ResendAttachment & { size: number }>;
}

// ── Helpers ────────────────────────────────────────────────────────

/**
 * Parse a from-address that may include a display name.
 * Examples: "Acme <onboarding@resend.dev>" → "onboarding@resend.dev"
 *           "user@example.com" → "user@example.com"
 */
function parseFromAddress(from: string): { name: string | null; address: string } {
	const match = from.match(/^"?(.+?)"?\s*<([^>]+)>$/);
	if (match) {
		return { name: match[1].trim(), address: match[2].trim().toLowerCase() };
	}
	return { name: null, address: from.trim().toLowerCase() };
}

/**
 * Extract a raw message-id value by stripping angle brackets and
 * whitespace. Falls back to the first whitespace-delimited token.
 */
function extractMsgId(s: string): string {
	const m = s.match(/<([^>]+)>/);
	return m ? m[1] : s.trim().split(/\s+/)[0];
}

/**
 * Find a specific header value from an array of { key, value } headers
 * (case-insensitive key lookup).
 */
function findHeader(
	headers: Array<{ key: string; value: string }>,
	name: string,
): string | null {
	const lower = name.toLowerCase();
	for (const h of headers) {
		if (h.key.toLowerCase() === lower) return h.value;
	}
	return null;
}

/**
 * Get the Resend API key, checking the domains table first,
 * then per-mailbox R2 settings, then the global env variable.
 */
async function getResendApiKey(
	env: Env,
	mailboxId: string,
): Promise<string | null> {
	// Read API key from per-mailbox R2 settings
	try {
		const obj = await env.BUCKET.get(`mailboxes/${mailboxId}.json`);
		if (obj) {
			const settings = await obj.json<Record<string, unknown>>();
			if (typeof settings.resendApiKey === "string" && settings.resendApiKey) {
				return settings.resendApiKey;
			}
		}
	} catch {
		// Ignore read errors
	}
	return null;
}

// ── Main Handler ───────────────────────────────────────────────────

export async function handleResendInbound(
	payload: ResendInboundPayload,
	env: Env,
	ctx: ExecutionContext,
): Promise<{ ok: boolean }> {
	const { data } = payload;

	// ── 1. Determine mailboxId from received_for or to addresses ──

	// Prefer received_for (the original recipient address) over `to`
	const candidateAddresses = [
		...data.received_for.map((a) => a.toLowerCase()),
		...data.to.map((a) => a.toLowerCase()),
	];

	let mailboxId: string | undefined;
	mailboxId = candidateAddresses[0];

	if (!mailboxId) {
		throw new Error("Resend inbound email has no valid recipient address");
	}

	// ── 2. Check mailbox exists ──

	const mailboxKey = `mailboxes/${mailboxId}.json`;
	if (!(await env.BUCKET.head(mailboxKey))) {
		// Mailbox not found — check for catch-all routing
		const domain = mailboxId.split("@")[1];
		const domainRecord = await dbService.getDomainByName(env.DB, domain);
		if (domainRecord?.catch_all_mailbox) {
			// Normalize: if stored as @domain (legacy), convert to *@domain
			mailboxId = domainRecord.catch_all_mailbox.startsWith("@") && !domainRecord.catch_all_mailbox.startsWith("*@")
				? `*${domainRecord.catch_all_mailbox}`
				: domainRecord.catch_all_mailbox;
			console.log(
				`No exact match for Resend inbound, routing to catch-all: ${mailboxId}`,
			);
		} else {
			console.log(
				`Ignoring Resend inbound for ${mailboxId}: mailbox does not exist`,
			);
			return { ok: true };
		}
	}

	// ── 3. Get Resend API key ──

	const apiKey = await getResendApiKey(env, mailboxId);
	if (!apiKey) {
		console.error(
			`Cannot process Resend inbound for ${mailboxId}: no Resend API key configured in mailbox settings`,
		);
		return { ok: false };
	}

	const baseUrl = "https://api.resend.com";

	// ── 4. Fetch full email content from Resend API ──

	const emailResponse = await fetchWithTimeout(
		`${baseUrl}/emails/receiving/${data.email_id}`,
		{
			headers: {
				Authorization: `Bearer ${apiKey}`,
				"Content-Type": "application/json",
			},
		},
	);

	if (!emailResponse.ok) {
		throw new Error(
			`Resend API error fetching email ${data.email_id}: ${emailResponse.status} ${emailResponse.statusText}`,
		);
	}

	const fullEmail: ResendFullEmail = await emailResponse.json();

	// ── 5. Parse sender / recipients ──

	const { name: senderName, address: senderAddress } = parseFromAddress(fullEmail.from);
	const sender = senderAddress.toLowerCase();

	const allRecipients = fullEmail.to
		.map((a) => a.toLowerCase())
		.filter(Boolean);
	const ccRecipients = (fullEmail.cc || [])
		.map((a) => a.toLowerCase())
		.filter(Boolean);
	const bccRecipients = (fullEmail.bcc || [])
		.map((a) => a.toLowerCase())
		.filter(Boolean);

	// ── 6. Download & store attachments in R2 ──

	const messageId = crypto.randomUUID();
	const attachmentData: StoredAttachment[] = [];

	if (fullEmail.attachments && fullEmail.attachments.length > 0) {
		for (const att of fullEmail.attachments) {
			const attId = crypto.randomUUID();
			const filename = (att.filename || "untitled").replace(
				/[\/\\:*?"<>|\x00-\x1f]/g,
				"_",
			);

			// Download attachment binary from Resend
			const attResponse = await fetchWithTimeout(
				`${baseUrl}/emails/receiving/${data.email_id}/attachments/${att.id}/download`,
				{
					headers: {
						Authorization: `Bearer ${apiKey}`,
					},
				},
			);

			if (!attResponse.ok) {
				console.error(
					`Failed to download attachment ${att.id} (${att.filename}): ${attResponse.status}`,
				);
				// Skip this attachment but continue processing
				continue;
			}

			const attBuffer = await attResponse.arrayBuffer();
			await env.BUCKET.put(
				`attachments/${messageId}/${attId}/${filename}`,
				attBuffer,
			);

			attachmentData.push({
				id: attId,
				email_id: messageId,
				filename,
				mimetype: att.content_type,
				size: attBuffer.byteLength,
				content_id: att.content_id || null,
				disposition: att.content_disposition || "attachment",
			});
		}
	}

	// ── 7. Extract threading info ──

	const headers = fullEmail.headers || [];

	const inReplyToRaw = findHeader(headers, "in-reply-to");
	const inReplyTo = inReplyToRaw ? extractMsgId(inReplyToRaw) : null;

	const referencesRaw = findHeader(headers, "references");
	const emailReferences = referencesRaw
		? referencesRaw
				.split(/\s+/)
				.filter(Boolean)
				.map(extractMsgId)
		: [];

	const originalMessageId = data.message_id
		? extractMsgId(data.message_id)
		: null;

	// ── 8. Determine threadId ──

	const db = env.DB;
	let threadId = emailReferences[0] || inReplyTo || messageId;

	if (!inReplyTo && emailReferences.length === 0) {
		// Fallback: try matching by subject
		try {
			const subjectThread = await dbService.findThreadBySubject(
				db,
				mailboxId,
				fullEmail.subject || "",
				sender || undefined,
			);
			if (subjectThread) threadId = subjectThread;
		} catch {
			// If findThreadBySubject fails, keep the default threadId
		}
	}

	// ── 9. Build raw_headers from the Resend headers array ──

	const rawHeaders = JSON.stringify(
		headers.length > 0
			? headers
			: [
					{ key: "from", value: fullEmail.from },
					{ key: "to", value: fullEmail.to.join(", ") },
					...(fullEmail.cc?.length
						? [{ key: "cc", value: fullEmail.cc.join(", ") }]
						: []),
					{ key: "subject", value: fullEmail.subject },
					{ key: "date", value: fullEmail.created_at },
					{ key: "message-id", value: `<${data.message_id}>` },
				],
	);

	// ── 10. Create email in D1 ──

	await dbService.createEmail(
		db,
		mailboxId,
		Folders.INBOX,
		{
			id: messageId,
			subject: fullEmail.subject || "",
			sender,
			sender_name: senderName,
			recipient: allRecipients.join(", "),
			cc: ccRecipients.join(", ") || null,
			bcc: bccRecipients.join(", ") || null,
			date: new Date().toISOString(),
			body: fullEmail.html || fullEmail.text || "",
			in_reply_to: inReplyTo,
			email_references:
				emailReferences.length > 0 ? JSON.stringify(emailReferences) : null,
			thread_id: threadId,
			message_id: originalMessageId,
			raw_headers: rawHeaders,
		},
		attachmentData,
	);

	return { ok: true };
}