configuration
Cloudflare Resources
Provision the Cloudflare resources Spinupmail expects and wire them into the backend Worker.
Spinupmail depends on a small set of Cloudflare primitives: D1 for relational storage, KV for supporting state, R2 for attachments, and a Durable Object namespace for abuse tracking. The README is the source of truth here: create the resources first, then copy their IDs and names into the backend Worker config.
Provision the required resources
Run the setup commands from packages/backend.
cd packages/backend
pnpm exec wrangler d1 create SUM_DB
pnpm exec wrangler kv namespace create SUM_KV
pnpm exec wrangler r2 bucket create spinupmail-attachments
pnpm exec wrangler r2 bucket create spinupmail-attachments-preview
pnpm wrangler queues create spinupmail-integration-dispatchesRecord the values each command returns. You will need them when you edit
wrangler.toml.
| Resource | Create command | Save these values |
|---|---|---|
| D1 database | pnpm exec wrangler d1 create SUM_DB | binding, database_name, database_id |
| KV namespace | pnpm exec wrangler kv namespace create SUM_KV | binding, id |
| R2 bucket | pnpm exec wrangler r2 bucket create spinupmail-attachments | bucket_name |
| R2 preview bucket | pnpm exec wrangler r2 bucket create spinupmail-attachments-preview | bucket_name |
| Integration dispatch queue | pnpm wrangler queues create spinupmail-integration-dispatches | queue_name |
Copy the backend Wrangler config
Start from the example config instead of building the file by hand.
cp wrangler.toml.example wrangler.tomlThen update the generated file with the resource values you just created.
[[d1_databases]]
binding = "SUM_DB"
database_name = "SUM_DB"
database_id = "YOUR_D1_DATABASE_ID"
[[kv_namespaces]]
binding = "SUM_KV"
id = "YOUR_KV_NAMESPACE_ID"
[[r2_buckets]]
binding = "R2_BUCKET"
bucket_name = "spinupmail-attachments"
preview_bucket_name = "spinupmail-attachments-preview"The example config already includes the Durable Object binding and migration:
[[migrations]]
tag = "v1"
new_sqlite_classes = ["InboundAbuseCounterDurableObject"]
[[durable_objects.bindings]]
name = "ABUSE_COUNTERS"
class_name = "InboundAbuseCounterDurableObject"Fill the Worker variables
Some runtime configuration lives in [vars] instead of secrets. These values
control routing, email limits, and optional product behavior.
Required variables
| Variable | Purpose |
|---|---|
EMAIL_DOMAINS | Comma-separated inbound domains handled by Email Routing, such as spinupmail.com or spinupmail.com,spinupmail.dev |
RESEND_FROM_EMAIL | Verified sender used for Better Auth verification and password-reset email |
Common optional variables
| Variable | Default or usage |
|---|---|
AUTH_ALLOWED_EMAIL_DOMAIN | Restricts sign-up and sign-in to one email domain for internal deployments |
FORCED_MAIL_PREFIX | Forces every created or renamed inbox local part to start with this prefix plus -; unset disables the feature |
EMAIL_MAX_BYTES | Max raw email bytes parsed by the Worker |
EMAIL_BODY_MAX_BYTES | Max HTML and text body bytes persisted in D1 |
EMAIL_FORWARD_TO | Optional forward target |
EMAIL_ATTACHMENT_MAX_BYTES | Max size per attachment uploaded to R2 |
EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION | Total attachment quota per organization, default 104857600 |
EMAIL_ATTACHMENTS_ENABLED | Toggle attachment processing, default true |
MAX_ADDRESSES_PER_ORGANIZATION | Address cap per org, default 10 |
MAX_RECEIVED_EMAILS_PER_ADDRESS | Default and hard cap for stored emails per inbox, default 100 |
MAX_RECEIVED_EMAILS_PER_ORGANIZATION | Total stored-email cap across one org, default 1000 |
MAX_INTEGRATIONS_PER_ORGANIZATION | Integration cap per org, default 3 |
MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY | Daily integration dispatch cap per org, default 100 |
INTEGRATION_QUEUE_RETRY_WINDOW_SECONDS | Retry window for integration dispatch failures, default 21600 |
INTEGRATION_QUEUE_BASE_DELAY_SECONDS | Initial retry delay for integration dispatches, default 30 |
INTEGRATION_QUEUE_MAX_DELAY_SECONDS | Maximum retry delay for integration dispatches, default 1800 |
INTEGRATION_QUEUE_JITTER_SECONDS | Retry jitter to spread retries, default 10 |
API_KEY_RATE_LIMIT_WINDOW and API_KEY_RATE_LIMIT_MAX | Rate limit window and max for x-api-key traffic |
AUTH_RATE_LIMIT_WINDOW and AUTH_RATE_LIMIT_MAX | Better Auth global rate limiting |
AUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOW and AUTH_CHANGE_EMAIL_RATE_LIMIT_MAX | Change-email rate limiting |
EMAIL_STORE_HEADERS_IN_DB | Persist full headers in D1 |
EMAIL_STORE_RAW_IN_DB | Persist raw MIME in D1 |
EMAIL_STORE_RAW_IN_R2 | Persist raw MIME in private R2 for debugging |
Domain strategy and routing prep
EMAIL_DOMAINS is the inbound source of truth. If you configure multiple
domains, Spinupmail treats them as valid receiving domains and the UI will show
a domain selector during address creation. If you configure only one domain,
the app uses it automatically.
If you also set FORCED_MAIL_PREFIX, Spinupmail displays that enforced prefix
in the create/edit address forms and always applies it on the backend when an
address is created or renamed.
If you want Spinupmail to operate as an internal tool, also set
AUTH_ALLOWED_EMAIL_DOMAIN. That keeps email/password auth and Google OAuth
aligned with one company domain while still letting EMAIL_DOMAINS define
where inbound mail is received.

