import { buildIntegrationDetailWidgetSpotId, type IntegrationBundle, type IntegrationDefinition, } from '@open-mercato/shared/modules/integrations/types' export const channelCloudflareEmailDetailWidgetSpotId = buildIntegrationDetailWidgetSpotId('channel_cloudflare_email') /** * Integration definition — drives the connect form and the integrations listing. * * ─── `id` is load-bearing and easy to get silently wrong ─── * * The hub's webhook route resolves credentials with * `credentialsService.resolve('channel_' + providerKey, scope)`. With * `providerKey: 'cloudflare_email'` that is exactly `channel_cloudflare_email`, * so this `id` must match character for character. * * The failure mode if it doesn't is nasty: the resolve throws, the route's * surrounding `catch` swallows it and substitutes `credentials = {}`, every * candidate channel then fails verification, and EVERY inbound webhook returns * 401 with no diagnostic pointing at the real cause. */ export const integration: IntegrationDefinition = { id: 'channel_cloudflare_email', title: 'Cloudflare Email', description: 'Receive mail through Cloudflare Email Routing and a thin Email Worker, and send through the Email Sending REST API. Push delivery — no polling, no OAuth, no mailbox credentials.', category: 'communication', hub: 'communication_channels', providerKey: 'cloudflare_email', icon: 'mail', docsUrl: 'https://developers.cloudflare.com/email-service/', package: '@northbound-run/channel-cloudflare-email', version: '0.1.0', author: 'Northbound', company: 'Northbound', license: 'MIT', tags: ['email', 'cloudflare', 'push', 'webhook', 'communication'], detailPage: { widgetSpotId: channelCloudflareEmailDetailWidgetSpotId, }, apiVersions: [ { id: 'email-service-v1', label: 'Cloudflare Email Service (Routing + Sending)', status: 'stable', default: true, changelog: 'Initial release. Inbound via Email Routing + Email Worker with HMAC-signed envelopes; outbound via the Email Sending REST API; delivery status via Queues event subscriptions.', }, ], credentials: { fields: [ { key: 'accountId', label: 'Cloudflare account ID', type: 'text', required: true, placeholder: '023e105f4ecef8ad9ca31a8372d0c353', helpText: 'Found in the Cloudflare dashboard URL, or under Account Home → Account ID.', }, { key: 'apiToken', label: 'API token', type: 'secret', required: true, // Zone:Read is NOT optional despite reading like a nicety: validation // resolves the zone with GET /zones?name=..., and a token without it // receives 200 with an empty list — indistinguishable from the domain // not existing. The connect form then rejects a perfectly good // sendingDomain with "no Cloudflare zone was found under this account", // sending the operator to fix a domain that was never the problem. // Email Routing:Read is genuinely optional, and only widens the health // check; without it inbound routing is reported as unchecked, not broken. helpText: 'Needs "Email Sending: Edit" AND "Zone: Zone: Read" — without zone read, validation cannot resolve the domain and wrongly reports the sending domain as missing. Add "Email Routing: Read" to let the health check verify inbound routing too. Account-owned tokens (cfat_…) are recommended over user tokens (cfut_…), which stop working when that user is removed. Stored encrypted at rest.', }, { key: 'sendingDomain', label: 'Sending domain', type: 'text', required: true, placeholder: 'ingest.example.com', helpText: 'Must already be onboarded under Email Service → Email Sending. Sends from any other domain are rejected with E_SENDER_NOT_VERIFIED.', }, { key: 'fromAddress', label: 'From address', type: 'text', required: true, placeholder: 'notifications@ingest.example.com', helpText: 'Default sender. Must be on the sending domain above.', }, { key: 'fromName', label: 'From name', type: 'text', required: false, placeholder: 'Acme Notifications', }, { key: 'inboundAddress', label: 'Inbound routing address', type: 'text', required: true, placeholder: 'inbox@ingest.example.com', helpText: 'The address your Email Routing rule delivers to the Worker. Mail is only seen from the moment this rule goes live — there is no backfill.', }, { key: 'ingestSecret', label: 'Worker ingest secret', type: 'secret', required: true, helpText: 'Shared HMAC secret. Must match the INGEST_SECRET set on the Email Worker. Use at least 32 random characters — this value authenticates inbound mail and pins it to your tenant.', }, { key: 'r2Bucket', label: 'R2 bucket name', type: 'text', required: true, placeholder: 'mail-archive', helpText: 'The bucket the Worker writes raw MIME and attachments to. Keep it PRIVATE — do not enable a public r2.dev URL or an unauthenticated custom domain. Reads are authenticated per request instead.', }, { key: 'r2AccessKeyId', label: 'R2 access key ID', type: 'text', required: true, helpText: 'From R2 → Manage API Tokens. Separate from the Cloudflare API token above — R2 uses an S3-style key pair.', }, { key: 'r2SecretAccessKey', label: 'R2 secret access key', type: 'secret', required: true, helpText: 'Needs Object Read on this bucket only — no write permission, since the Worker writes through its R2 binding. Stored encrypted.', }, ], }, healthCheck: { service: 'channelCloudflareEmailHealthCheck' }, } export const integrations: IntegrationDefinition[] = [integration] export const bundles: IntegrationBundle[] = [] export const bundle: IntegrationBundle | undefined = undefined