import { NextResponse } from 'next/server' import { createRequestContainer } from '@open-mercato/shared/lib/di/container' import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' import type { EntityManager } from '@mikro-orm/postgresql' import { CommunicationChannel, MessageChannelLink, } from '@open-mercato/core/modules/communication_channels/data/entities' import { createLogger } from '@open-mercato/shared/lib/logger' import { MAX_SIGNED_BODY_BYTES, isDeliveryEventEnvelope } from '../../../lib/envelope' import { verifyPayload } from '../../../lib/hmac' import { parseCredentialsOrThrow } from '../../../lib/credentials' import { mapDeliveryEvent } from '../../../lib/delivery-events' const logger = createLogger('channel_cloudflare_email').child({ component: 'delivery-events' }) /** * Receive Cloudflare Email Sending delivery events. * * ══ WHY THIS ROUTE EXISTS INSTEAD OF THE CHANNEL WEBHOOK ══ * * Delivery events cannot travel through the hub's channel webhook. That route * answers any `eventType` other than `'message'` with a 202 and `queued: false` * (`communication_channels/api/post/webhook/[provider]/route.ts:180-189`) — the * caller sees HTTP success while the event is silently discarded. Worse than an * error, because nothing alerts and the status quietly goes stale forever. * * So the module owns a verified endpoint of its own. It shares only the HMAC * helper with the inbound path. * * `metadata` MUST be present and MUST say `requireAuth: false`. Omitting it * silently defaults to auth-required and every POST from the Worker gets a 401. */ export const metadata = { path: '/channel_cloudflare_email/delivery-events', POST: { // Unauthenticated by design — the HMAC signature IS the authentication, and // it is also what pins the request to a tenant. Rate-limited because the // handler fans out an O(N) cross-tenant candidate scan. requireAuth: false, rateLimit: { points: 240, duration: 60, keyPrefix: 'cf_email_delivery_events' }, }, } export async function POST(req: Request): Promise { // ── Size guards, BEFORE reading or hashing anything ── // // Copied from `inbox_ops/api/webhook/inbound.ts:214-216` and `:227-229` rather // than approximated. Core's channel webhook has neither check (it does a bare // `await req.text()` at `route.ts:63`), so on this route — where we own the // whole handler — both halves are done properly. // // A 413 is safe to return plainly. Unlike the 401 below it leaks nothing about // tenancy: it is a property of the request alone, so there is no oracle here // and a clear answer is more useful to the operator than a blanket rejection. const declaredLength = Number(req.headers.get('content-length') ?? '') if (Number.isFinite(declaredLength) && declaredLength > MAX_SIGNED_BODY_BYTES) { return NextResponse.json({ error: 'payload_too_large' }, { status: 413 }) } let rawBody: string try { rawBody = await req.text() } catch { // Mirrors `inbox_ops/api/webhook/inbound.ts:220-224` — a broken/truncated // read is a malformed request, not a server failure, so it gets a plain // 400 rather than falling into the 500 handler below. return NextResponse.json({ error: 'bad_request' }, { status: 400 }) } // Content-Length is caller-supplied and may lie, so re-check the real body. if (new TextEncoder().encode(rawBody).length > MAX_SIGNED_BODY_BYTES) { return NextResponse.json({ error: 'payload_too_large' }, { status: 413 }) } const headers: Record = {} req.headers.forEach((value, key) => { headers[key] = value }) const container = await createRequestContainer() const em = (container.resolve('em') as EntityManager).fork() type CredentialsServiceLike = { resolve: ( integrationId: string, scope: { organizationId: string; tenantId: string; userId?: string | null }, ) => Promise | null> } let credentialsService: CredentialsServiceLike | null = null try { credentialsService = container.resolve('integrationCredentialsService') } catch { credentialsService = null } try { // The tenant MUST be derived from a channel whose own secret verifies the // signature — never from anything in the payload. const candidates = (await findWithDecryption( em, CommunicationChannel, { providerKey: 'cloudflare_email', isActive: true, deletedAt: null }, { orderBy: { createdAt: 'desc' } }, )) as CommunicationChannel[] let matched: CommunicationChannel | null = null for (const candidate of candidates) { const scope = { tenantId: candidate.tenantId, organizationId: candidate.organizationId ?? candidate.tenantId, userId: candidate.userId ?? null, } let credentials: Record = {} if (candidate.credentialsRef && credentialsService) { try { // The integration id must be exactly `channel_cloudflare_email` — it is // `channel_`. A mismatch resolves nothing, every candidate // fails, and every event 401s with no diagnostic. credentials = (await credentialsService.resolve('channel_cloudflare_email', scope)) ?? {} } catch { credentials = {} } } let ingestSecret: string try { ingestSecret = parseCredentialsOrThrow(credentials).ingestSecret } catch { // Unusable credentials mean "not this tenant" — move on. continue } const verification = await verifyPayload(ingestSecret, rawBody, headers) if (verification.ok) { matched = candidate break } } if (!matched) { // Uniform response. Distinguishing "bad signature" from "no such tenant" // here would let a caller enumerate tenants; detail goes to logs only. logger.warn('delivery event verification failed against every candidate channel') return NextResponse.json({ error: 'verification_failed' }, { status: 401 }) } const parsed: unknown = JSON.parse(rawBody) if (!isDeliveryEventEnvelope(parsed)) { // Signed by a legitimate Worker but structurally wrong — a version skew // between Worker and module. Worth a 400 and a loud log, not a 401. logger.warn('delivery event payload failed structural validation', { channelId: matched.id, }) return NextResponse.json({ error: 'invalid_envelope' }, { status: 400 }) } // MikroORM v7 dropped the Knex builder in favour of Kysely/raw SQL, and a // nested-object filter like `{ channelMetadata: { messageId } }` is not a // supported translation for a JSONB path comparison — `communication_ // channels/commands/ingest-inbound-message.ts:121-135` and `lib/thread- // matcher.ts:285-304` hit exactly this and moved to a raw // `channel_metadata->>'messageId'` query for the same reason. Mirrored // here rather than trusting an unproven ORM filter shape for a lookup // that, if silently empty, would make every delivery event a no-op. const linkIdRows = await em.getConnection().execute>( `SELECT id FROM message_channel_links WHERE tenant_id = ? AND ((?::uuid IS NULL AND organization_id IS NULL) OR organization_id = ?::uuid) AND provider_key = 'cloudflare_email' AND direction = 'outbound' AND channel_metadata->>'messageId' = ? LIMIT 1`, [ matched.tenantId, matched.organizationId ?? null, matched.organizationId ?? null, parsed.messageId, ], ) const linkId = linkIdRows?.[0]?.id const link = linkId ? await em.findOne(MessageChannelLink, { id: linkId }) : null if (!link) { // Not an error worth retrying: the event may relate to a message sent // before this channel existed, or one whose link was deleted. Acknowledge // so the Worker's queue consumer does not redeliver forever. logger.info('no outbound link matched delivery event', { messageId: parsed.messageId, channelId: matched.id, }) return NextResponse.json({ received: true, matched: false }, { status: 202 }) } const outcome = mapDeliveryEvent(parsed, link.deliveryStatus) link.channelMetadata = { ...((link.channelMetadata as Record | undefined) ?? {}), ...outcome.metadataPatch, } if (outcome.nextStatus) { link.deliveryStatus = outcome.nextStatus } await em.flush() return NextResponse.json( { received: true, matched: true, status: outcome.nextStatus ?? link.deliveryStatus, }, { status: 200 }, ) } catch (error: unknown) { logger.error('delivery event processing failed', { err: error }) // 500 rather than 202: the Worker's queue consumer should retry a genuine // processing failure, unlike an unmatched message which is terminal. return NextResponse.json({ error: 'processing_failed' }, { status: 500 }) } } export const openApi = { tags: ['ChannelCloudflareEmail'], summary: 'Receive Cloudflare Email Sending delivery events', methods: { POST: { summary: 'Apply a delivery/bounce/complaint event to the originating outbound message link', tags: ['ChannelCloudflareEmail'], responses: [ { status: 200, description: 'Event applied to a matching outbound link' }, { status: 202, description: 'Verified, but no outbound link matched — not retried' }, { status: 400, description: 'Verified but structurally invalid envelope' }, { status: 401, description: 'Signature verification failed against every channel' }, { status: 413, description: 'Body exceeds the signing limit' }, { status: 500, description: 'Processing failure — safe to retry' }, ], }, }, } export default POST