diff --git a/README.md b/README.md index f105bee..df4d3f6 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ OpenInbox is built for everyday conversations. It is not a bulk or marketing sen **Mail** - Receive on any domain in your Cloudflare account. Email Routing is set up for you. -- Send through Resend, Brevo, Mailjet or any SMTP server. When one provider hits its free limit, mail falls back to another. +- Send through Resend, Brevo, Mailjet, Cloudflare Email Sending or any SMTP server. When one provider hits its free limit, mail falls back to another. - Threads, All Mail, search (`from:` `has:attachment` `before:`), labels, filters, snooze, scheduled and undo send. - Command palette and keyboard shortcuts, dark mode, mobile layout. @@ -128,7 +128,7 @@ Everything runs inside your own Cloudflare account. There is no OpenInbox server | **Cloudflare Email Routing** | Receives mail for your domains and hands it to the Worker | | **One Worker** | Runs the web app, the API, inbound mail handling and scheduled jobs | | **One D1 database** | Stores mailboxes, messages, users and settings | -| **A sending provider** | Delivers outgoing mail (Resend, Brevo, Mailjet or SMTP), with fallback between them | +| **A sending provider** | Delivers outgoing mail (Resend, Brevo, Mailjet, Cloudflare or SMTP), with fallback between them | | **R2 (optional)** | Keeps large attachments | ## Deploy @@ -137,7 +137,7 @@ Everything runs inside your own Cloudflare account. There is no OpenInbox server - A free Cloudflare account, with a domain on Cloudflare's nameservers. - A GitHub account. -- A free account with a sending provider. This is only needed to send; receiving works without one. +- A free account with a sending provider. This is only needed to send; receiving works without one. To send through Cloudflare itself instead, the account needs the Workers Paid plan. ### Steps @@ -175,7 +175,7 @@ Everything runs inside your own Cloudflare account. There is no OpenInbox server
-The token's permissions (filled in for you by this link) +The token's permissions (filled in for you by this link) | Scope | Permission | Access | | | --- | --- | --- | --- | @@ -188,6 +188,7 @@ Everything runs inside your own Cloudflare account. There is no OpenInbox server | Zone | Workers Routes | Edit | optional: app on `mail.yourdomain` | | Zone | Analytics | Read | optional: delivery log | | Account | Email Routing Addresses | Edit | optional: forwarding | +| Account | Email Sending | Edit | optional: send through Cloudflare | | Account | Account Analytics | Read | optional: usage on the dashboard | | Account | Workers R2 Storage | Edit | optional: attachments bucket | @@ -200,7 +201,7 @@ Everything runs inside your own Cloudflare account. There is no OpenInbox server | Worker requests | 100,000 / day per account | | D1 storage | 500 MB, roughly 50,000–100,000 messages | | Incoming message size | 25 MB | -| Sending | Resend 100/day, Brevo 300/day, Mailjet 200/day | +| Sending | Resend 100/day, Brevo 300/day, Mailjet 200/day; Cloudflare 3,000/month on Workers Paid | Mail is stored text-only by default, and attachments are kept only on messages under 950 KB. To keep every attachment, turn on R2 storage in **Admin → Integrations → Attachments**. R2 has a 10 GB free tier, but Cloudflare requires a payment method on the account first. diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d60a702..eea220b 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -34,7 +34,7 @@ export const DEFAULT_SETTINGS = { export type SpamPolicy = 'spam' | 'reject' | 'off'; export type RemoteImagesPolicy = 'block' | 'allow'; -export const PROVIDER_TYPES = ['resend', 'brevo', 'mailjet', 'smtp'] as const; +export const PROVIDER_TYPES = ['resend', 'brevo', 'mailjet', 'cloudflare', 'smtp'] as const; export type ProviderType = (typeof PROVIDER_TYPES)[number]; // Free-tier sending caps, used as defaults when a provider is connected. Users can change them. @@ -45,6 +45,7 @@ export const PROVIDER_INFO: Record = { resend: { label: 'Resend', @@ -53,6 +54,7 @@ export const PROVIDER_INFO: Record p.domains.filter((x) => x.domainId === d.id)); - const sendState = sending.some((x) => x.status === 'verified') ? 'ok' : sending.some((x) => x.status === 'pending') ? 'pending' : 'off'; + const noDomainApi = providers.some((p) => !PROVIDER_INFO[p.type].hasDomainApi); + const sendState = sending.some((x) => x.status === 'verified') ? 'ok' : sending.some((x) => x.status === 'pending') ? 'pending' : noDomainApi ? 'ok' : 'off'; const issues = health?.items.filter((h) => h.status !== 'ok') ?? []; const errors = issues.filter((h) => h.status === 'error'); const tone = d.status === 'error' || errors.length ? 'danger' : !d.setup.done || d.status === 'pending' || issues.length ? 'warning' : d.status === 'active' ? 'success' : 'neutral'; diff --git a/src/web/pages/admin/IntegrationsPage.tsx b/src/web/pages/admin/IntegrationsPage.tsx index 00e0bdd..3bafb24 100644 --- a/src/web/pages/admin/IntegrationsPage.tsx +++ b/src/web/pages/admin/IntegrationsPage.tsx @@ -29,10 +29,10 @@ export function IntegrationsPage() { ); } -const PROVIDER_ACCENT: Record = { resend: '#111111', brevo: '#0b996e', mailjet: '#fea500', smtp: '#6366f1' }; +const PROVIDER_ACCENT: Record = { resend: '#111111', brevo: '#0b996e', mailjet: '#fea500', cloudflare: '#f6821f', smtp: '#6366f1' }; function ProviderMark({ type, size = 38 }: { type: ProviderType; size?: number }) { - const letter = { resend: 'R', brevo: 'B', mailjet: 'M', smtp: '@' }[type]; + const letter = { resend: 'R', brevo: 'B', mailjet: 'M', cloudflare: 'C', smtp: '@' }[type]; return (
{letter} @@ -168,7 +168,7 @@ function ProviderCard({ provider: p, onChanged }: { provider: ProviderInfo; onCh } onClick={() => setEditing(true)}> Edit limits and credentials - {p.type !== 'smtp' && ( + {PROVIDER_INFO[p.type].hasWebhookApi && ( } onClick={() => @@ -192,7 +192,7 @@ function ProviderCard({ provider: p, onChanged }: { provider: ProviderInfo; onCh
- {p.type !== 'smtp' && (p.domains.length > 0 || unconfigured.length > 0) && ( + {PROVIDER_INFO[p.type].hasDomainApi && (p.domains.length > 0 || unconfigured.length > 0) && (
{p.domains.map((d) => (
@@ -255,9 +255,11 @@ function ProviderCard({ provider: p, onChanged }: { provider: ProviderInfo; onCh ))}
)} - {p.type === 'smtp' && ( + {!PROVIDER_INFO[p.type].hasDomainApi && (
- SMTP has no domain API: publish your SMTP provider's SPF and DKIM records for each domain yourself. Domain health shows whether SPF includes them. + {p.type === 'cloudflare' + ? 'Cloudflare has no sending-domain API: enable Email Sending for each domain in the Cloudflare dashboard (Email Service > Email Sending). Cloudflare manages SPF and DKIM once the domain is onboarded there.' + : "SMTP has no domain API: publish your SMTP provider's SPF and DKIM records for each domain yourself. Domain health shows whether SPF includes them."}
)} {editing && ( @@ -297,6 +299,7 @@ function ProviderCard({ provider: p, onChanged }: { provider: ProviderInfo; onCh } function credentialFields(type: ProviderType) { + if (type === 'cloudflare') return []; if (type === 'smtp') return [ { key: 'host', label: 'SMTP server', placeholder: 'smtp.example.com' }, @@ -313,6 +316,7 @@ function credentialFields(type: ProviderType) { } const KEY_HELP: Record = { + cloudflare: { href: 'https://developers.cloudflare.com/email-service/', text: 'Sends with the Cloudflare API token OpenInbox already uses, so there is no key to paste. Cloudflare requires a Workers Paid plan, and each sending domain has to be onboarded in the dashboard first.' }, resend: { href: 'https://resend.com/api-keys', text: 'Create a key with Full access in Resend → API Keys (full access lets OpenInbox verify domains for you).' }, brevo: { href: 'https://app.brevo.com/settings/keys/api', text: 'Create an API key in Brevo → SMTP & API → API Keys.' }, mailjet: { href: 'https://app.mailjet.com/account/apikeys', text: 'Find your API key and secret key in Mailjet → Account settings → API Key Management.' }, @@ -366,7 +370,7 @@ function ConnectDialog({ type, onClose, onConnected }: { type: ProviderType; onC open onClose={result ? onConnected : onClose} title={`Connect ${info.label}`} - description={type === 'smtp' ? 'Any provider that gives you SMTP credentials.' : `Free tier: ${info.freeTier}.`} + description={type === 'smtp' ? 'Any provider that gives you SMTP credentials.' : type === 'cloudflare' ? 'Sends through Cloudflare with the API token you connected. No separate credentials.' : `Free tier: ${info.freeTier}.`} footerStart={!result && encrypted} footer={ result ? ( @@ -503,7 +507,7 @@ function EditProviderDialog({ provider: p, onClose, onSaved }: { provider: Provi setPriority(e.target.value)} />
-
+ {credentialFields(p.type).length > 0 &&
Credentials (leave masked values to keep them)
{credentialFields(p.type).map((f) => ( @@ -518,7 +522,7 @@ function EditProviderDialog({ provider: p, onClose, onSaved }: { provider: Provi )} -
+
} {p.type === 'resend' && ( setSecret(e.target.value)} placeholder="whsec_..." className="font-mono" /> diff --git a/src/worker/cloudflare/token.ts b/src/worker/cloudflare/token.ts index b5857a2..32dde56 100644 --- a/src/worker/cloudflare/token.ts +++ b/src/worker/cloudflare/token.ts @@ -93,6 +93,9 @@ export async function checkPermissions(cf: Cloudflare, accountId: string, zoneId : Promise.resolve(noZone), workers_scripts: () => probe(() => cf.get(`/accounts/${accountId}/workers/scripts`)), email_routing_addresses: () => probe(() => cf.get(`/accounts/${accountId}/email/routing/addresses?per_page=5`)), + // 10105 means the permission is there but the account is not on Workers Paid: that is an + // account state, not a missing permission. + email_sending: () => probe(() => cf.get(`/accounts/${accountId}/email/sending/suppressions?per_page=1`), [10105]), account_analytics: () => probe(() => cf.graphql( diff --git a/src/worker/db/migrations/0003_cloudflare_provider.sql b/src/worker/db/migrations/0003_cloudflare_provider.sql new file mode 100644 index 0000000..7958c0e --- /dev/null +++ b/src/worker/db/migrations/0003_cloudflare_provider.sql @@ -0,0 +1,69 @@ +-- Cloudflare Email Sending is a new provider type. SQLite cannot widen a CHECK constraint in +-- place, so providers is rebuilt with the wider list. Dropping it cascades into provider_domains +-- and quota_usage, and sets the sending override on domains and messages to NULL, so everything +-- that points at a provider is copied aside first and restored in the same transaction. +-- defer_foreign_keys moves the checks to commit, when the tables are whole again. + +PRAGMA defer_foreign_keys = ON; + +CREATE TABLE _provider_domains_backup AS SELECT * FROM provider_domains; +CREATE TABLE _quota_usage_backup AS SELECT * FROM quota_usage; +CREATE TABLE _domain_provider_backup AS SELECT id, provider_id FROM domains WHERE provider_id IS NOT NULL; +CREATE TABLE _message_provider_backup AS SELECT id, provider_id FROM messages WHERE provider_id IS NOT NULL; + +CREATE TABLE providers_new ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL CHECK (type IN ('resend', 'brevo', 'mailjet', 'smtp', 'cloudflare')), + name TEXT NOT NULL, + config TEXT NOT NULL, -- encrypted JSON credentials + status TEXT NOT NULL DEFAULT 'unknown' CHECK (status IN ('unknown', 'connected', 'error')), + last_error TEXT, + checked_at INTEGER, + priority INTEGER NOT NULL DEFAULT 0, -- fallback order, lowest first + is_default INTEGER NOT NULL DEFAULT 0, + daily_limit INTEGER, + monthly_limit INTEGER, + webhook_secret TEXT, -- encrypted + webhook_id TEXT, + created_at INTEGER NOT NULL +); + +INSERT INTO providers_new (id, type, name, config, status, last_error, checked_at, priority, is_default, daily_limit, monthly_limit, webhook_secret, webhook_id, created_at) + SELECT id, type, name, config, status, last_error, checked_at, priority, is_default, daily_limit, monthly_limit, webhook_secret, webhook_id, created_at FROM providers; + +DROP TABLE provider_domains; +DROP TABLE quota_usage; +DROP TABLE providers; + +ALTER TABLE providers_new RENAME TO providers; + +CREATE TABLE provider_domains ( + provider_id INTEGER NOT NULL REFERENCES providers (id) ON DELETE CASCADE, + domain_id INTEGER NOT NULL REFERENCES domains (id) ON DELETE CASCADE, + external_id TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'verified', 'failed')), + records TEXT NOT NULL DEFAULT '[]', + last_error TEXT, + checked_at INTEGER, + PRIMARY KEY (provider_id, domain_id) +); +CREATE INDEX provider_domains_domain ON provider_domains (domain_id); +INSERT INTO provider_domains SELECT * FROM _provider_domains_backup; + +CREATE TABLE quota_usage ( + provider_id INTEGER NOT NULL REFERENCES providers (id) ON DELETE CASCADE, + day TEXT NOT NULL, -- UTC YYYY-MM-DD + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (provider_id, day) +); +INSERT INTO quota_usage SELECT * FROM _quota_usage_backup; + +UPDATE domains SET provider_id = (SELECT b.provider_id FROM _domain_provider_backup b WHERE b.id = domains.id) + WHERE id IN (SELECT id FROM _domain_provider_backup); +UPDATE messages SET provider_id = (SELECT b.provider_id FROM _message_provider_backup b WHERE b.id = messages.id) + WHERE id IN (SELECT id FROM _message_provider_backup); + +DROP TABLE _provider_domains_backup; +DROP TABLE _quota_usage_backup; +DROP TABLE _domain_provider_backup; +DROP TABLE _message_provider_backup; diff --git a/src/worker/providers/cloudflare.ts b/src/worker/providers/cloudflare.ts new file mode 100644 index 0000000..8def0af --- /dev/null +++ b/src/worker/providers/cloudflare.ts @@ -0,0 +1,133 @@ +// Cloudflare Email Sending (https://developers.cloudflare.com/email-service/). Sends through the +// Cloudflare API with the token OpenInbox already stores, so connecting it needs no second +// credential. Cloudflare generates the Message-ID and rejects it in `headers`, so replies thread +// through the References the send pipeline appends (the same as Resend). +import type { Env } from '../env'; +import { loadSettings } from '../db/settings'; +import { cfToken, Cloudflare, CfError, DEFAULT_CF_API } from '../cloudflare/client'; +import { + ProviderError, + threadingHeaders, + toBase64, + type DomainSetup, + type OutgoingAttachment, + type OutgoingMessage, + type ProviderAdapter, + type SendResult, +} from './types'; + +// Nothing is stored per provider: the token and the account id come from the instance settings. +export type CloudflareConfig = Record; + +interface SendResponse { + delivered?: string[]; + permanent_bounces?: string[]; + queued?: string[]; + suppressed_recipients?: string[]; + message_id?: string; +} + +const sendPath = (accountId: string): string => `/accounts/${encodeURIComponent(accountId)}/email/sending/send`; + +// Cloudflare error codes worth saying out loud instead of a bare HTTP status. +function providerError(e: unknown): ProviderError { + if (e instanceof ProviderError) return e; + if (!(e instanceof CfError)) return new ProviderError(e instanceof Error ? e.message : String(e)); + const code = e.errors[0]?.code; + const detail = e.errors.map((x) => x.message).join('; ') || `HTTP ${e.status}`; + const retryable = e.status === 429 || e.status >= 500; + const failed = (message: string) => new ProviderError(message, retryable, e.status, code ? String(code) : undefined); + switch (code) { + case 10101: + return failed('Cloudflare rejected the API token. Reconnect it in Integrations > Cloudflare.'); + case 10102: + return failed('The Cloudflare API token needs the Email Sending permission. Recreate it from Integrations > Cloudflare.'); + case 10105: + return failed('Cloudflare Email Sending requires the Workers Paid plan on this account.'); + case 10200: + return failed('Cloudflare rejected the message: it is larger than 25 MB including attachments.'); + case 10203: + return failed('Email Sending is not enabled for this domain in Cloudflare (Email Service > Email Sending), or it is switched off.'); + case 10207: + return failed('Email Sending is paused for this Cloudflare account.'); + default: + return new ProviderError(`Cloudflare: ${detail}`, retryable, e.status, code ? String(code) : undefined); + } +} + +function recipient(a: { name?: string; address: string }): { address: string; name?: string } { + return a.name ? { address: a.address, name: a.name } : { address: a.address }; +} + +function attachment(a: OutgoingAttachment) { + const base = { content: toBase64(a.content), filename: a.filename, type: a.contentType }; + return a.inline && a.contentId ? { ...base, disposition: 'inline', content_id: a.contentId } : { ...base, disposition: 'attachment' }; +} + +export class CloudflareAdapter implements ProviderAdapter { + readonly type = 'cloudflare' as const; + + constructor(private env: Env) {} + + // The token and the account id live in the instance settings, so one Cloudflare connection serves + // inbound routing and outbound sending alike. + private async client() { + const settings = await loadSettings(this.env.DB); + const token = await cfToken(this.env, settings); + if (!token) throw new ProviderError('Connect a Cloudflare API token first (Integrations > Cloudflare).'); + if (!settings.cfAccountId) throw new ProviderError('OpenInbox does not know your Cloudflare account yet. Finish setup first.'); + return { cf: new Cloudflare(token, this.env.CF_API_BASE || DEFAULT_CF_API), accountId: settings.cfAccountId }; + } + + // Listing the suppression list is the cheapest call behind the Email Sending permission; a token + // without it, or an account that is not entitled, answers 401/403 instead of a list. + async validate(): Promise<{ account?: string; note?: string }> { + const { cf, accountId } = await this.client(); + try { + await cf.request('GET', `/accounts/${encodeURIComponent(accountId)}/email/sending/suppressions?per_page=1`); + return { account: 'Cloudflare Email Sending', note: 'Cloudflare accepted the token. Enable Email Sending for each domain you send from (Email Service > Email Sending in the dashboard).' }; + } catch (e) { + throw providerError(e); + } + } + + async send(m: OutgoingMessage): Promise { + const { cf, accountId } = await this.client(); + const headers = threadingHeaders(m); + // Message-ID is generated by Cloudflare; sending it is rejected with E_HEADER_NOT_ALLOWED. + delete headers['Message-ID']; + let r: SendResponse; + try { + r = await cf.request('POST', sendPath(accountId), { + to: m.to.map(recipient), + from: recipient(m.from), + cc: m.cc.length ? m.cc.map(recipient) : undefined, + bcc: m.bcc.length ? m.bcc.map(recipient) : undefined, + reply_to: m.replyTo?.address, + subject: m.subject, + text: m.text || ' ', + html: m.html ?? undefined, + headers: Object.keys(headers).length ? headers : undefined, + attachments: m.attachments.length ? m.attachments.map(attachment) : undefined, + }); + } catch (e) { + throw providerError(e); + } + const gone = [...(r.permanent_bounces ?? []), ...(r.suppressed_recipients ?? [])]; + if (gone.length && !(r.delivered?.length || r.queued?.length)) { + throw new ProviderError(`Cloudflare did not accept the message for ${gone.join(', ')}.`); + } + return { providerMessageId: r.message_id?.replace(/^<|>$/g, '') ?? null }; + } + + // Cloudflare exposes sending and suppressions over the API, but not onboarding a sending domain: + // that is the dashboard or `wrangler email sending enable`. The provider row is hidden from the + // domain setup UI (hasDomainApi: false), so these only guard the service layer. + async addDomain(domain: string): Promise { + throw new ProviderError(`Enable Email Sending for ${domain} in the Cloudflare dashboard (Email Service > Email Sending), then send from it here.`); + } + + async checkDomain(domain: string): Promise { + throw new ProviderError(`Cloudflare does not verify sending domains over the API. Check ${domain} in the Cloudflare dashboard.`); + } +} diff --git a/src/worker/providers/index.ts b/src/worker/providers/index.ts index a99deb9..c34c4c7 100644 --- a/src/worker/providers/index.ts +++ b/src/worker/providers/index.ts @@ -4,12 +4,13 @@ import { decryptJson } from '../lib/crypto'; import { badRequest } from '../lib/errors'; import type { ProviderType } from '../../shared/constants'; import { BrevoAdapter, type BrevoConfig } from './brevo'; +import { CloudflareAdapter, type CloudflareConfig } from './cloudflare'; import { MailjetAdapter, type MailjetConfig } from './mailjet'; import { ResendAdapter, type ResendConfig } from './resend'; import { SmtpAdapter, type SmtpConfig } from './smtp'; import type { ProviderAdapter } from './types'; -export type ProviderConfig = ResendConfig | BrevoConfig | MailjetConfig | SmtpConfig; +export type ProviderConfig = ResendConfig | BrevoConfig | MailjetConfig | CloudflareConfig | SmtpConfig; export interface ProviderRow { id: number; @@ -36,6 +37,8 @@ export function createAdapter(env: Env, type: ProviderType, config: ProviderConf return new BrevoAdapter(config as BrevoConfig, env.BREVO_API_BASE || undefined); case 'mailjet': return new MailjetAdapter(config as MailjetConfig, env.MAILJET_API_BASE || undefined); + case 'cloudflare': + return new CloudflareAdapter(env); case 'smtp': return new SmtpAdapter(config as SmtpConfig); default: @@ -50,6 +53,7 @@ export async function adapterFor(env: Env, row: ProviderRow): Promise): ProviderConfig { const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string).trim() : ''); + if (type === 'cloudflare') return {}; if (type === 'resend' || type === 'brevo') { if (!str('apiKey')) throw badRequest('Enter the API key.'); return { apiKey: str('apiKey') }; @@ -69,6 +73,7 @@ export function parseProviderConfig(type: ProviderType, input: Record { const mask = (s: string) => (s.length <= 8 ? '••••' : `${s.slice(0, 4)}••••${s.slice(-4)}`); + if (type === 'cloudflare') return {}; if (type === 'smtp') { const c = config as SmtpConfig; return { host: c.host, port: c.port, security: c.security, username: c.username, password: c.password ? '••••••••' : '' }; diff --git a/src/worker/routes/admin.ts b/src/worker/routes/admin.ts index 6f16220..67ca0ea 100644 --- a/src/worker/routes/admin.ts +++ b/src/worker/routes/admin.ts @@ -28,6 +28,7 @@ import { badRequest, conflict, errorMessage, HttpError, notFound } from '../lib/ import { decrypt, encrypt, encryptJson } from '../lib/crypto'; import { newId } from '../lib/ids'; import { parseJson } from '../lib/json'; +import { PROVIDER_INFO } from '../../shared/constants'; import { audit } from '../services/audit'; import { overview } from '../services/overview'; import { cloudflareUsage, growthEstimate, storageSummary } from '../services/usage'; @@ -313,7 +314,9 @@ admin.post('/providers', async (c) => { const r = await connectProvider(c.env, settings, input); await audit(c.env.DB, { userId: currentUser(c).id, action: 'provider.connected', target: input.type, ip: clientIp(c) }); // Set up sending for every domain right away; failures show per domain and can be retried. - const domains = await all<{ id: number }>(c.env.DB, "SELECT id FROM domains WHERE status != 'disabled'"); + // Providers with no domain API (Cloudflare, SMTP) onboard domains elsewhere, so there is nothing + // to register here. + const domains = PROVIDER_INFO[input.type].hasDomainApi ? await all<{ id: number }>(c.env.DB, "SELECT id FROM domains WHERE status != 'disabled'") : []; const results = []; for (const d of domains) { try { diff --git a/src/worker/services/domains.ts b/src/worker/services/domains.ts index 926d1e1..ca8c82f 100644 --- a/src/worker/services/domains.ts +++ b/src/worker/services/domains.ts @@ -1,6 +1,7 @@ // Domain automation. Each step is idempotent and its state is saved in domains.setup_state, so // setup can resume after a failure or a closed tab, and "repair" simply runs the steps again. import type { Env } from '../env'; +import { PROVIDER_INFO, type ProviderType } from '../../shared/constants'; import type { AppSettings } from '../db/settings'; import { all, first, run } from '../db/db'; import { CfError, cfFromEnv, type Cloudflare } from '../cloudflare/client'; @@ -343,7 +344,16 @@ export async function checkDomainHealth(env: Env, settings: AppSettings, domainI detail: p.status === 'verified' ? 'Domain verified.' : p.last_error || 'Waiting for verification.', }); } - if (!providers.length) items.push({ key: 'provider', label: 'Sending', status: 'warn', detail: 'No sending provider set up for this domain yet.' }); + if (!providers.length) { + // Cloudflare and SMTP have no domain API: once connected they can send for any domain, so there + // is nothing to verify and no row in provider_domains. + const noApi = (await all<{ name: string; type: string }>(env.DB, 'SELECT name, type FROM providers')).filter((p) => !PROVIDER_INFO[p.type as ProviderType].hasDomainApi); + items.push( + noApi.length + ? { key: 'provider', label: 'Sending', status: 'ok', detail: `Handled by ${noApi.map((p) => p.name).join(', ')}; no per-domain verification.` } + : { key: 'provider', label: 'Sending', status: 'warn', detail: 'No sending provider set up for this domain yet.' }, + ); + } let conflicts: DomainHealth['conflicts']; try { diff --git a/src/worker/services/overview.ts b/src/worker/services/overview.ts index bc5975f..fe1c321 100644 --- a/src/worker/services/overview.ts +++ b/src/worker/services/overview.ts @@ -6,6 +6,7 @@ import { all, first } from '../db/db'; import { dayKey, DAY } from '../lib/time'; import { parseJson } from '../lib/json'; import { allQuotaUse } from './quota'; +import { PROVIDER_INFO, type ProviderType } from '../../shared/constants'; export async function overview(env: Env, settings: AppSettings) { const db = env.DB; @@ -32,7 +33,7 @@ export async function overview(env: Env, settings: AppSettings) { db, 'SELECT id, name, status, health, app_hostname, catch_all_mailbox_id FROM domains ORDER BY name', ), - all<{ id: number; type: string; name: string; status: string; daily_limit: number | null; monthly_limit: number | null; is_default: number; last_error: string | null }>( + all<{ id: number; type: ProviderType; name: string; status: string; daily_limit: number | null; monthly_limit: number | null; is_default: number; last_error: string | null }>( db, 'SELECT id, type, name, status, daily_limit, monthly_limit, is_default, last_error FROM providers ORDER BY is_default DESC, priority', ), @@ -62,7 +63,7 @@ export async function overview(env: Env, settings: AppSettings) { const checklist = [ { id: 'domain', label: 'Receive mail on your domain', done: domains.some((d) => d.status === 'active'), href: '/admin/domains' }, { id: 'provider', label: 'Connect a sending provider', done: hasProvider, href: '/admin/integrations' }, - { id: 'sending', label: 'Verify your domain for sending', done: domains.some((d) => verifiedIds.has(d.id)) || providers.some((p) => p.type === 'smtp'), href: '/admin/domains' }, + { id: 'sending', label: 'Verify your domain for sending', done: domains.some((d) => verifiedIds.has(d.id)) || providers.some((p) => !PROVIDER_INFO[p.type].hasDomainApi), href: '/admin/domains' }, { id: 'users', label: 'Add your team or more inboxes', done: (counts?.mailboxes ?? 0) > 1 || (counts?.users ?? 0) > 1, href: '/admin/users', optional: true }, { id: 'attachments', label: 'Enable attachments (R2)', done: settings.storageMode === 'r2', href: '/admin/integrations#storage', optional: true }, { id: 'appdomain', label: 'Open the app on mail.yourdomain', done: domains.some((d) => !!d.app_hostname), href: '/admin/domains', optional: true }, diff --git a/src/worker/services/send.ts b/src/worker/services/send.ts index 6cd943d..ac4a910 100644 --- a/src/worker/services/send.ts +++ b/src/worker/services/send.ts @@ -4,6 +4,7 @@ // -> send with our own Message-ID, also appended to References -> archive the sent .eml and store // the provider message ID, or mark failed (retryable failures retry from cron with backoff). import type { Env } from '../env'; +import { PROVIDER_INFO } from '../../shared/constants'; import { loadSettings, type AppSettings } from '../db/settings'; import { all, first, placeholders, run } from '../db/db'; import { newId } from '../lib/ids'; @@ -301,8 +302,9 @@ async function providerCandidates(db: D1Database, fromDomain: string): Promise

Integrations).'); - // SMTP has no domain verification; the others need the domain verified with them. - const usable = rows.filter((p) => p.type === 'smtp' || p.pd_status === 'verified'); + // Providers without a domain API (SMTP, Cloudflare) have nothing to verify here; the rest need + // the domain verified with them. + const usable = rows.filter((p) => !PROVIDER_INFO[p.type].hasDomainApi || p.pd_status === 'verified'); if (!usable.length) throw new HttpError(409, 'domain_not_verified', `${fromDomain} is not verified with a sending provider yet (Admin > Domains).`); return domain.provider_id ? usable.filter((p) => p.id === domain.provider_id).concat(usable.filter((p) => p.id !== domain.provider_id)) : usable; } diff --git a/test/integration/cloudflare-provider.test.ts b/test/integration/cloudflare-provider.test.ts new file mode 100644 index 0000000..a717ebb --- /dev/null +++ b/test/integration/cloudflare-provider.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useFakeApis } from '../helpers/fake'; +import { Client, deliver, email, setupInstance } from '../helpers/client'; + +describe('Cloudflare Email Sending provider', () => { + let admin: Client; + let fake: ReturnType; + let domainId: number; + + beforeEach(async () => { + fake = useFakeApis(); + admin = new Client(); + ({ domainId } = await setupInstance(admin)); + }); + + it('connects with the stored Cloudflare token and sends through the API', async () => { + const connected = await admin.post('/api/admin/providers', { type: 'cloudflare', config: {} }); + expect(connected.id).toBeGreaterThan(0); + // No domain API, so connecting does not try to register domains. + expect(connected.domains).toEqual([]); + + const boxes = await admin.get('/api/mailboxes'); + const draft = await admin.post('/api/drafts', { + mailboxId: boxes[0].id, + from: 'admin@example.com', + to: [{ name: 'Ann', address: 'ann@dest.test' }], + subject: 'Sent through Cloudflare', + html: '

Hello

', + }); + expect((await admin.post(`/api/drafts/${draft.id}/send`, {})).status).toBe('sent'); + + const out = fake.state.cfSending.emails[0] as { + from: { address: string; name?: string }; + to: { address: string; name?: string }[]; + subject: string; + headers?: Record; + }; + expect(out.from.address).toBe('admin@example.com'); + expect(out.to).toEqual([{ address: 'ann@dest.test', name: 'Ann' }]); + expect(out.subject).toBe('Sent through Cloudflare'); + // Cloudflare generates Message-ID and rejects it in headers; threading rides on References. + expect(out.headers?.['Message-ID']).toBeUndefined(); + + // The reply to a message sent this way still threads, through the References we appended. + const threads = (await admin.get('/api/threads?mailbox=all&view=sent')).threads; + expect(threads).toHaveLength(1); + const sent = await admin.get(`/api/threads/${threads[0].id}`); + const ownId = sent.messages[0].messageId as string; + await deliver(email({ subject: 'Re: Sent through Cloudflare', messageId: 'r1@dest.test', inReplyTo: ownId, references: `<${ownId}>` }), { + from: 'ann@dest.test', + to: 'admin@example.com', + }); + const after = await admin.get(`/api/threads/${threads[0].id}`); + expect(after.messages.map((m: { direction: string }) => m.direction)).toEqual(['out', 'in']); + }); + + it('counts as sending for the domain without a per-domain verified row', async () => { + await admin.post('/api/admin/providers', { type: 'cloudflare', config: {} }); + const health = await admin.post(`/api/admin/domains/${domainId}/check`, {}); + const sending = health.items.find((i: { key: string }) => i.key === 'provider'); + expect(sending).toMatchObject({ status: 'ok' }); + expect(sending.detail).toMatch(/Cloudflare/); + }); + + it('says the account needs Workers Paid instead of a bare Cloudflare error', async () => { + fake.state.cfSending.entitled = false; + const res = await admin.request('POST', '/api/admin/providers', { type: 'cloudflare', config: {} }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(await res.text()).toMatch(/Workers Paid/); + expect(await admin.get('/api/admin/providers')).toHaveLength(0); + }); + + it('marks a Cloudflare failure for retry, then sends it', async () => { + await admin.post('/api/admin/providers', { type: 'cloudflare', config: {} }); + const boxes = await admin.get('/api/mailboxes'); + const draft = await admin.post('/api/drafts', { mailboxId: boxes[0].id, from: 'admin@example.com', to: [{ name: '', address: 'x@dest.test' }], subject: 'Retry me', html: '

Hi

' }); + + fake.state.cfSending.failNext = 1; + expect((await admin.post(`/api/drafts/${draft.id}/send`, {})).status).toBe('failed'); + expect(fake.state.cfSending.emails).toHaveLength(0); + + expect((await admin.post(`/api/messages/${draft.id}/retry`, {})).status).toBe('sent'); + expect(fake.state.cfSending.emails).toHaveLength(1); + }); +}); diff --git a/test/mock/fake-apis.ts b/test/mock/fake-apis.ts index 1c44a40..36e8fd9 100644 --- a/test/mock/fake-apis.ts +++ b/test/mock/fake-apis.ts @@ -39,6 +39,7 @@ export interface FakeState { resend: { domains: { id: string; name: string; status: string; records: Record[] }[]; emails: Record[]; failNext: number; webhooks: string[] }; brevo: { domains: Record; emails: Record[]; credits: number }; mailjet: { dns: Record; emails: Record[] }; + cfSending: { entitled: boolean; failNext: number; emails: Record[] }; release: { tag_name: string; html_url: string; body: string } | null; requests: { method: string; url: string; body?: unknown }[]; seq: number; @@ -72,6 +73,7 @@ export function createFakeState(): FakeState { resend: { domains: [], emails: [], failNext: 0, webhooks: [] }, brevo: { domains: {}, emails: [], credits: 300 }, mailjet: { dns: {}, emails: [] }, + cfSending: { entitled: true, failNext: 0, emails: [] }, release: { tag_name: 'v1.0.0', html_url: 'https://github.com/example/openinbox/releases/tag/v1.0.0', body: 'First release.' }, requests: [], seq: 1, @@ -172,6 +174,23 @@ function cloudflare(state: FakeState, req: Request, url: URL, path: string, body state.customDomains = state.customDomains.filter((d) => d.id !== r![1]); return new Response('', { status: 200 }); } + if (/^\/accounts\/[^/]+\/email\/sending\/suppressions$/.test(path)) { + if (!state.cfSending.entitled) return fail(403, 10105, 'email.sending.error.authentication.not_entitled'); + return ok([]); + } + if ((r = /^\/accounts\/[^/]+\/email\/sending\/send$/.exec(path)) && m === 'POST') { + if (!state.cfSending.entitled) return fail(403, 10105, 'email.sending.error.authentication.not_entitled'); + // Cloudflare checks the request shape before sending anything, which is what validate() relies on. + if (!body?.to || !body?.from || !body?.subject) return fail(400, 10001, 'email.sending.error.invalid_request_schema'); + if (state.cfSending.failNext > 0) { + state.cfSending.failNext--; + return fail(500, 10002, 'email.sending.error.internal_server'); + } + const address = (a: unknown) => (typeof a === 'string' ? a : String((a as { address?: string })?.address ?? '')); + const recipients = [body.to, body.cc, body.bcc].flat().filter(Boolean).map(address); + state.cfSending.emails.push(body); + return ok({ delivered: recipients, permanent_bounces: [], queued: [], message_id: `<${id(state, 'cfmail')}@cf.test>` }); + } if (/^\/accounts\/[^/]+\/email\/routing\/addresses$/.test(path)) { if (m === 'POST') { const existing = state.destinations.find((d) => d.email === body.email);