get started
Installation
Install Spinupmail, configure Cloudflare, and deploy the backend and frontend.
Spinupmail is a Cloudflare Email Routing + Workers app for creating disposable addresses, storing inbound email, and reading messages in the UI or over the API.
Prerequisites
- A Cloudflare account with a domain using Cloudflare nameservers
- Cloudflare Email Routing enabled for the domain
pnpminstalled locally
Install dependencies
From the repo root:
pnpm installConfigure Cloudflare resources
Open the backend folder first:
cd packages/backendCreate D1 database
pnpm exec wrangler d1 create SUM_DBSave the returned binding, database_name, and database_id.
Create KV namespace
pnpm exec wrangler kv namespace create SUM_KVSave the returned binding and id.
Create R2 bucket
pnpm exec wrangler r2 bucket create spinupmail-attachmentsSave the returned bucket_name. In packages/backend/wrangler.toml, keep the
Worker binding as R2_BUCKET and set bucket_name to your real Cloudflare
bucket name.
Create queue (integration dispatches)
Spinupmail dispatches integration events asynchronously through a queue. Create the queue used for integration dispatch jobs:
pnpm wrangler queues create spinupmail-integration-dispatchesSee Integrations for provider setup and subscription flow.
Durable objects
This project already includes the Durable Object binding and migration in
packages/backend/wrangler.toml.example.
You do not need to run a separate create command for Durable Objects. Cloudflare creates the namespace when you deploy the Worker with that migration.
Update packages/backend/wrangler.toml with your values:
[[d1_databases]].database_id[[kv_namespaces]].id[[r2_buckets]].bucket_name[[r2_buckets]].preview_bucket_name[vars].EMAIL_DOMAINS[vars].RESEND_FROM_EMAIL
Optional vars from the README:
AUTH_ALLOWED_EMAIL_DOMAINFORCED_MAIL_PREFIXEMAIL_MAX_BYTESEMAIL_BODY_MAX_BYTESEMAIL_FORWARD_TOEMAIL_ATTACHMENT_MAX_BYTESEMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATIONEMAIL_ATTACHMENTS_ENABLEDMAX_ADDRESSES_PER_ORGANIZATIONAPI_KEY_RATE_LIMIT_WINDOWAPI_KEY_RATE_LIMIT_MAXAUTH_RATE_LIMIT_WINDOWAUTH_RATE_LIMIT_MAXAUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOWAUTH_CHANGE_EMAIL_RATE_LIMIT_MAXEMAIL_STORE_HEADERS_IN_DBEMAIL_STORE_RAW_IN_DBEMAIL_STORE_RAW_IN_R2
For local development, create packages/backend/.dev.vars:
BETTER_AUTH_BASE_URL="http://localhost:8787/api/auth"
BETTER_AUTH_SECRET=""
INTEGRATION_SECRET_ENCRYPTION_KEY=""
CORS_ORIGIN="http://localhost:5173,http://127.0.0.1:5173"
EXTENSION_REDIRECT_ORIGINS="https://<your-extension-id>.chromiumapp.org"
RESEND_API_KEY=""
TURNSTILE_SECRET_KEY=""
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""Backend environment variables and secrets
Set production secrets from packages/backend:
pnpm exec wrangler secret put BETTER_AUTH_BASE_URL
pnpm exec wrangler secret put BETTER_AUTH_SECRET
pnpm exec wrangler secret put INTEGRATION_SECRET_ENCRYPTION_KEY
pnpm exec wrangler secret put CORS_ORIGIN
pnpm exec wrangler secret put RESEND_API_KEY
pnpm exec wrangler secret put TURNSTILE_SECRET_KEY
pnpm exec wrangler secret put GOOGLE_CLIENT_ID
pnpm exec wrangler secret put GOOGLE_CLIENT_SECRETUse values like these:
BETTER_AUTH_BASE_URL = https://api.<your-domain>/api/authEXTENSION_REDIRECT_ORIGINS = https://<your-extension-id>.chromiumapp.org[,https://<your-firefox-extension-id>.extensions.allizom.org]GOOGLE_CLIENT_ID = <google oauth web client id>GOOGLE_CLIENT_SECRET = <google oauth web client secret>INTEGRATION_SECRET_ENCRYPTION_KEY = <base64 32-byte key; generate with openssl rand -base64 32>used to encrypt provider credentials (for example Telegram bot tokens)RESEND_API_KEY = re_...TURNSTILE_SECRET_KEY = <Cloudflare Turnstile secret key>RESEND_FROM_EMAILshould be set inwrangler.tomlunder[vars]FORCED_MAIL_PREFIX = tempforces all created and renamed inbox local parts to start withtemp-
Creating Turnstile key
- Open the Cloudflare dashboard.
- Open Turnstile.
- Click Add widget.
- Add your frontend hostnames, such as
localhost,127.0.0.1, and your production frontend domain. - Keep the widget in Managed mode.
- Create the widget and copy the site key and secret key.
- Save the secret key with:
pnpm exec wrangler secret put TURNSTILE_SECRET_KEY- Set the frontend env value:
- Cloudflare Pages:
VITE_TURNSTILE_SITE_KEY=<your site key> - Local dev:
packages/frontend/.env
- Cloudflare Pages:
Use the secret key only on the backend. Use the site key only in the frontend.
Add domain to Resend and get API key
- Sign in to Resend.
- Add your sending domain.
- Add the required DNS records in Cloudflare.
- Wait until the domain is verified in Resend.
- Create an API key with permission to send email.
- Save it with:
pnpm exec wrangler secret put RESEND_API_KEY- Set
[vars].RESEND_FROM_EMAILinpackages/backend/wrangler.tomlto a verified sender, for exampleSpinupmail <verify@mail.your-domain.com>. - Add
RESEND_API_KEY=...topackages/backend/.dev.varsfor local use.
Google OAuth setup
- Create a Google Cloud project.
- Open APIs & Services -> OAuth consent screen.
- Set up the consent screen.
- Create an OAuth client with application type Web application.
- Add these authorized JavaScript origins:
http://127.0.0.1:5173http://localhost:5173https://<your-frontend-domain>.com
- Add these redirect URIs:
http://localhost:8787/api/auth/callback/googlehttps://api.<your-domain>/api/auth/callback/googlehttps://<your-frontend-domain>/api/auth/callback/googleonly if you intentionally route the Worker under/api/*on the frontend host
- Save the credentials with:
pnpm exec wrangler secret put GOOGLE_CLIENT_ID
pnpm exec wrangler secret put GOOGLE_CLIENT_SECRETFor local development, also add GOOGLE_CLIENT_ID and
GOOGLE_CLIENT_SECRET to packages/backend/.dev.vars.
CORS_ORIGIN must include your frontend origin or Better Auth callback checks
will fail.
EXTENSION_REDIRECT_ORIGINS must list exact browser-extension redirect origins
(full origin with scheme and host, for example
https://<extension-id>.chromiumapp.org) allowed to complete the hosted
extension sign-in flow; wildcard patterns like *.chromiumapp.org or
*.extensions.allizom.org are rejected.
Database migrations
Do not hand-edit migrations.
pnpm -C packages/backend db:generate
pnpm -C packages/backend db:migrate:dev
# for production:
pnpm -C packages/backend db:migrate:prodDeploy the backend worker
pnpm -C packages/backend deployIf you want automatic backend deploys in Cloudflare:
- Open Workers & Pages.
- Open your Worker.
- Open Settings.
- Under Build -> Git Repository, click Connect.
- Select your forked repository.
- Leave Build command empty.
- Set Deploy command to
pnpm run deploy. - Set Root directory to
packages/backend.
Configure email routing
- Open Email Routing in Cloudflare.
- Click Onboard Domain.
- Select the correct zone.
- Open the domain.
- In Routing Rules, add a catch-all rule:
- Custom Address: Catch All
- Action: Send to a worker
- Destination: your deployed Worker
If you use multiple domains, repeat this for each domain in EMAIL_DOMAINS.
Configure custom domain for Worker API
- Open Workers & Pages.
- Open your Worker.
- Open Settings.
- In Domains & Routes, click Add.
- Choose Custom domain.
- Enter your API domain, for example
api.spinupmail.com.
Use a dedicated API subdomain as the default production setup. Only fall back
to a frontend-host /api/* route if you specifically need a same-host
topology.
Worker config
Example values in packages/backend/wrangler.toml:
[vars]
EMAIL_DOMAINS = "spinupmail.com,spinupmail.dev"
FORCED_MAIL_PREFIX = "temp"
AUTH_ALLOWED_EMAIL_DOMAIN = "example.com"
MAX_ADDRESSES_PER_ORGANIZATION = "100"
API_KEY_RATE_LIMIT_WINDOW = "60"
API_KEY_RATE_LIMIT_MAX = "120"
AUTH_RATE_LIMIT_WINDOW = "60"
AUTH_RATE_LIMIT_MAX = "100"
AUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOW = "3600"
AUTH_CHANGE_EMAIL_RATE_LIMIT_MAX = "2"
EMAIL_ATTACHMENTS_ENABLED = "true"
EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION = "104857600"UI behavior
If you configure more than one domain, the UI shows a domain selector when you create an address. If there is only one domain, it is used automatically.
If FORCED_MAIL_PREFIX is set, the create and edit forms show that prefix in
the username input group. The backend still enforces it, so API clients cannot
opt out by sending a different localPart.
Deploy the frontend
Create a Pages project, not a Worker.
- Open Workers & Pages.
- Click the link to deploy Pages.
- Import your forked repository.
- Use these settings:
- Framework preset:
None - Build command:
pnpm run build - Build output directory:
dist - Root directory:
packages/frontend
- Framework preset:
Set these environment variables in Pages:
VITE_AUTH_BASE_URL=https://api.spinupmail.com/api/auth
VITE_API_BASE_URL=https://api.spinupmail.com
VITE_TURNSTILE_SITE_KEY=<your site key>Local environment variables
Create packages/frontend/.env:
VITE_AUTH_BASE_URL=http://localhost:8787/api/auth
VITE_API_BASE_URL=http://localhost:8787
VITE_TURNSTILE_SITE_KEY=<Your Site Key>Set up frontend custom domain
After Pages is deployed:
- Open Custom domains in your Pages project.
- Click Set up a custom domain.
- Enter your frontend domain, for example
app.spinupmail.com. - Confirm the DNS record.
- Activate the domain.
API usage
Generate an API key from the UI and include X-Org-Id in API key requests.
List addresses:
curl "https://api.your-domain.com/api/email-addresses" \
-H "X-API-Key: <your_api_key>" \
-H "X-Org-Id: <organization_id>"List emails:
curl "https://api.your-domain.com/api/emails?address=john-123@your-domain.com" \
-H "X-API-Key: <your_api_key>" \
-H "X-Org-Id: <organization_id>"Download an attachment:
curl -L "https://api.your-domain.com/api/emails/<email_id>/attachments/<attachment_id>" \
-H "X-API-Key: <your_api_key>" \
-H "X-Org-Id: <organization_id>" \
--output attachment.binLocal development
Start the backend:
pnpm -C packages/backend devStart the frontend:
pnpm -C packages/frontend devThe frontend dev server proxies /api/* to http://127.0.0.1:8787.
Local email testing
Cloudflare does not deliver real emails to .workers.dev domains. For local
testing, use Wrangler's local email endpoint:
curl --location 'http://localhost:8787/cdn-cgi/handler/email?from=sender%40example.com&to=test-82bdbbd2%40spinupmail.com' \
--header 'Content-Type: application/json' \
--data-raw 'Received: from smtp.example.com (127.0.0.1)
by cloudflare-email.com (unknown) id 4fwwffRXOpyR
for <recipient@example.com>; Tue, 27 Aug 2024 15:50:20 +0000
From: "John" <sender@example.com>
Reply-To: sender@example.com
To: recipient@example.com
Subject: Testing Email Workers Local Dev
Content-Type: text/html; charset="windows-1252"
X-Mailer: Curl
Date: Tue, 27 Aug 2024 08:49:44 -0700
Message-ID: <MAKE-THIS-UNIQUE-6114391943504294873000@ZSH-GHOSTTY>
Hi there'Set a unique Message-ID for each test email.
To test real delivery, use a real domain in Cloudflare Email Routing and point the rule to your Worker.
Notes
- Email addresses must be created before email is sent.
- Unknown addresses are rejected.
- HTML is sanitized on the backend, checked again on the frontend, and rendered inside a Shadow DOM container.

