Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -175,7 +175,7 @@ Everything runs inside your own Cloudflare account. There is no OpenInbox server
</table>

<details>
<summary>The token's permissions (filled in for you by <a href="https://dash.cloudflare.com/?to=/:account/api-tokens&amp;permissionGroupKeys=%5B%7B%22key%22%3A%22c8fed203ed3043cba015a93ad1616f1f%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%224755a26eedb94da69e1066d98aa820be%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%223030687196b94b638145a3953da2b699%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%2279b3ec0d10ce4148a8f8bdc0cc5f97f2%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%2228f4b596e7d643029c524985477ae49a%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%229c88f9c5bce24ce7af9a958ba9c504db%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22e086da7e2179491d91ee5f35b3ca210a%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22e4589eb09e63436686cd64252a3aebeb%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22b89a480218d04ceb98b4fe57ca29dc1f%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22c1fde68c7bcc44588cbb6ddbc16d6480%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bf7481a1826f439697cb59a20b22293e%22%2C%22type%22%3A%22edit%22%7D%5D&amp;name=OpenInbox">this link</a>)</summary>
<summary>The token's permissions (filled in for you by <a href="https://dash.cloudflare.com/?to=/:account/api-tokens&amp;permissionGroupKeys=%5B%7B%22key%22%3A%22c8fed203ed3043cba015a93ad1616f1f%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%224755a26eedb94da69e1066d98aa820be%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%223030687196b94b638145a3953da2b699%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%2279b3ec0d10ce4148a8f8bdc0cc5f97f2%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%2228f4b596e7d643029c524985477ae49a%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%229c88f9c5bce24ce7af9a958ba9c504db%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22e086da7e2179491d91ee5f35b3ca210a%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22e4589eb09e63436686cd64252a3aebeb%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%225df633d6b41c42bcaf5b4a62b9d14b64%22%2C%22type%22%3A%22edit%22%7D%2C%7B%22key%22%3A%22b89a480218d04ceb98b4fe57ca29dc1f%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22c1fde68c7bcc44588cbb6ddbc16d6480%22%2C%22type%22%3A%22read%22%7D%2C%7B%22key%22%3A%22bf7481a1826f439697cb59a20b22293e%22%2C%22type%22%3A%22edit%22%7D%5D&amp;name=OpenInbox">this link</a>)</summary>

| Scope | Permission | Access | |
| --- | --- | --- | --- |
Expand All @@ -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 |

Expand All @@ -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.

Expand Down
17 changes: 16 additions & 1 deletion src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -45,6 +45,7 @@ export const PROVIDER_INFO: Record<ProviderType, {
monthlyLimit: number | null;
freeTier: string;
hasDomainApi: boolean;
hasWebhookApi: boolean;
}> = {
resend: {
label: 'Resend',
Expand All @@ -53,6 +54,7 @@ export const PROVIDER_INFO: Record<ProviderType, {
monthlyLimit: 3000,
freeTier: '3,000 emails/month, 100/day',
hasDomainApi: true,
hasWebhookApi: true,
},
brevo: {
label: 'Brevo',
Expand All @@ -61,6 +63,7 @@ export const PROVIDER_INFO: Record<ProviderType, {
monthlyLimit: null,
freeTier: '300 emails/day',
hasDomainApi: true,
hasWebhookApi: true,
},
mailjet: {
label: 'Mailjet',
Expand All @@ -69,6 +72,16 @@ export const PROVIDER_INFO: Record<ProviderType, {
monthlyLimit: 6000,
freeTier: '6,000 emails/month, 200/day',
hasDomainApi: true,
hasWebhookApi: true,
},
cloudflare: {
label: 'Cloudflare',
website: 'https://developers.cloudflare.com/email-service/',
dailyLimit: null,
monthlyLimit: 3000,
freeTier: '3,000 emails/month on Workers Paid',
hasDomainApi: false,
hasWebhookApi: false,
},
smtp: {
label: 'SMTP',
Expand All @@ -77,6 +90,7 @@ export const PROVIDER_INFO: Record<ProviderType, {
monthlyLimit: null,
freeTier: 'Depends on your provider',
hasDomainApi: false,
hasWebhookApi: false,
},
};

Expand Down Expand Up @@ -104,6 +118,7 @@ export const TOKEN_PERMISSIONS = [
{ key: 'analytics', id: '9c88f9c5bce24ce7af9a958ba9c504db', type: 'read', scope: 'Zone', name: 'Analytics', access: 'Read', why: 'Show Email Routing delivery logs', optional: true },
{ key: 'workers_scripts', id: 'e086da7e2179491d91ee5f35b3ca210a', type: 'edit', scope: 'Account', name: 'Workers Scripts', access: 'Edit', why: 'Find this Worker and its database', optional: false },
{ key: 'email_routing_addresses', id: 'e4589eb09e63436686cd64252a3aebeb', type: 'edit', scope: 'Account', name: 'Email Routing Addresses', access: 'Edit', why: 'Verify addresses for forwarding', optional: true },
{ key: 'email_sending', id: '5df633d6b41c42bcaf5b4a62b9d14b64', type: 'edit', scope: 'Account', name: 'Email Sending', access: 'Edit', why: 'Send through Cloudflare instead of another provider', optional: true },
{ key: 'account_analytics', id: 'b89a480218d04ceb98b4fe57ca29dc1f', type: 'read', scope: 'Account', name: 'Account Analytics', access: 'Read', why: 'Show free-tier usage on the dashboard', optional: true },
{ key: 'account_settings', id: 'c1fde68c7bcc44588cbb6ddbc16d6480', type: 'read', scope: 'Account', name: 'Account Settings', access: 'Read', why: 'Show your account name (recommended)', optional: true },
{ key: 'workers_r2', id: 'bf7481a1826f439697cb59a20b22293e', type: 'edit', scope: 'Account', name: 'Workers R2 Storage', access: 'Edit', why: 'Create the attachments bucket (optional feature)', optional: true },
Expand Down
7 changes: 5 additions & 2 deletions src/web/pages/admin/DomainsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { del, get, patch, post, errorText } from '../../lib/api';
import { timeAgo } from '../../lib/format';
import { useAction, useInvalidate } from '../../lib/hooks';
import { cn } from '../../lib/utils';
import { PROVIDER_INFO } from '../../../shared/constants';
import type { DomainInfo, DomainSetupStep, MailboxAdmin, ProviderInfo, ZoneInspection } from '../../lib/types';
import { Button, IconButton } from '../../components/ui/button';
import { Checkbox, Field, Select } from '../../components/ui/form';
Expand Down Expand Up @@ -125,9 +126,11 @@ function DomainCard({ domain: d, mailboxes, providers, onChanged }: { domain: Do
}
};

// Sending is verified per provider; the domain sends once any provider has verified it.
// Sending is verified per provider; the domain sends once any provider has verified it, or once a
// provider that has no domain API (Cloudflare, SMTP) is connected.
const sending = providers.flatMap((p) => 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';
Expand Down
22 changes: 13 additions & 9 deletions src/web/pages/admin/IntegrationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ export function IntegrationsPage() {
);
}

const PROVIDER_ACCENT: Record<ProviderType, string> = { resend: '#111111', brevo: '#0b996e', mailjet: '#fea500', smtp: '#6366f1' };
const PROVIDER_ACCENT: Record<ProviderType, string> = { 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 (
<div className="flex shrink-0 items-center justify-center rounded-[9px] font-bold text-white" style={{ width: size, height: size, background: PROVIDER_ACCENT[type], fontSize: size * 0.42 }}>
{letter}
Expand Down Expand Up @@ -168,7 +168,7 @@ function ProviderCard({ provider: p, onChanged }: { provider: ProviderInfo; onCh
<MenuItem icon={<Pencil />} onClick={() => setEditing(true)}>
Edit limits and credentials
</MenuItem>
{p.type !== 'smtp' && (
{PROVIDER_INFO[p.type].hasWebhookApi && (
<MenuItem
icon={<Webhook />}
onClick={() =>
Expand All @@ -192,7 +192,7 @@ function ProviderCard({ provider: p, onChanged }: { provider: ProviderInfo; onCh
</MenuItem>
</Menu>
</div>
{p.type !== 'smtp' && (p.domains.length > 0 || unconfigured.length > 0) && (
{PROVIDER_INFO[p.type].hasDomainApi && (p.domains.length > 0 || unconfigured.length > 0) && (
<div className="border-t border-border">
{p.domains.map((d) => (
<div key={d.domainId} className="border-b border-border last:border-0">
Expand Down Expand Up @@ -255,9 +255,11 @@ function ProviderCard({ provider: p, onChanged }: { provider: ProviderInfo; onCh
))}
</div>
)}
{p.type === 'smtp' && (
{!PROVIDER_INFO[p.type].hasDomainApi && (
<div className="border-t border-border px-5 py-3 text-xs text-muted text-pretty">
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."}
</div>
)}
{editing && (
Expand Down Expand Up @@ -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' },
Expand All @@ -313,6 +316,7 @@ function credentialFields(type: ProviderType) {
}

const KEY_HELP: Record<ProviderType, { href: string; text: string }> = {
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.' },
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -503,7 +507,7 @@ function EditProviderDialog({ provider: p, onClose, onSaved }: { provider: Provi
<Input type="number" value={priority} onChange={(e) => setPriority(e.target.value)} />
</Field>
</div>
<div className="space-y-3 rounded-[10px] border border-border p-3.5">
{credentialFields(p.type).length > 0 && <div className="space-y-3 rounded-[10px] border border-border p-3.5">
<div className="text-xs font-medium text-muted">Credentials (leave masked values to keep them)</div>
{credentialFields(p.type).map((f) => (
<Field key={f.key} label={f.label}>
Expand All @@ -518,7 +522,7 @@ function EditProviderDialog({ provider: p, onClose, onSaved }: { provider: Provi
</Select>
</Field>
)}
</div>
</div>}
{p.type === 'resend' && (
<Field label="Webhook signing secret" hint="Only if you registered the webhook yourself in the Resend dashboard (whsec_…).">
<Input value={secret} onChange={(e) => setSecret(e.target.value)} placeholder="whsec_..." className="font-mono" />
Expand Down
3 changes: 3 additions & 0 deletions src/worker/cloudflare/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
69 changes: 69 additions & 0 deletions src/worker/db/migrations/0003_cloudflare_provider.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading