Self-host unlimited temporary emails with attachments on Cloudflare
Spinupmail is an open-source temporary email platform for teams, built on Cloudflare Email Routing and Workers. It lets organizations create unlimited mailboxes on their own domains, capture inbound messages (including attachments), and manage everything through a secure Better Auth + Hono API and a modern React + Shadcn dashboard.
- Create unlimited email addresses scoped to an organization
- Create and join organizations (max 3 per user, max 10 members per org, both configurable)
- Receive emails via Cloudflare Email Routing and store them in D1
- Browse organization-scoped emails in the UI
- Store inbound mail attachments in Cloudflare R2 and download them in UI/API
- Generate API keys for automation (e.g., test suites)
- Route inbound email events to integrations for real-time notifications (Telegram provider available)
- A Cloudflare account with a domain using Cloudflare nameservers
- Email Routing enabled for the domain
packages/backend— Cloudflare Worker (Hono + Better Auth + D1 + KV + R2)packages/frontend— React + shadcn UI (Vite)
packages/backend/src/index.ts— Worker entrypoint and API compositionpackages/backend/src/app/— app types and shared middlewarepackages/backend/src/modules/— domain modules (auth-http,domains,organizations,email-addresses,emails,inbound-email,integrations)packages/backend/src/shared/— shared constants, helpers, validation, and utilitiespackages/backend/src/platform/— platform integrations (auth runtime and DB client)
- Backend TypeScript uses path aliases with
@/*mapped topackages/backend/src/*. - Prefer
@/shared/...,@/modules/...,@/platform/..., and@/app/...for cross-folder imports. - Keep
./...imports for files in the same folder.
From the repo root:
pnpm installOpen the backend folder:
cd packages/backendpnpm exec wrangler d1 create SUM_DBSave the returned binding, database_name, and database_id for the next steps.
pnpm exec wrangler kv namespace create SUM_KVSave the returned binding and id for the next steps.
Create buckets for attachment storage:
pnpm exec wrangler r2 bucket create spinupmail-attachmentsSave the returned bucket_name values for the next steps. In
packages/backend/wrangler.toml, keep the Worker binding as R2_BUCKET and
set bucket_name to the actual Cloudflare bucket names.
Spinupmail uses a queue worker to dispatch integration events in the background. Create the queue used for integration dispatch jobs:
pnpm wrangler queues create spinupmail-integration-dispatchesThis backend already includes the Durable Object binding and migration in
packages/backend/wrangler.toml.example:
[[durable_objects.bindings]][[migrations]]withnew_sqlite_classes = ["InboundAbuseCounterDurableObject"]
For a fresh project, you do not run a separate "create durable object" command. Cloudflare creates the Durable Object namespace when you deploy the Worker with that migration, and individual Durable Object instances are created automatically the first time the backend uses them.
Edit packages/backend/wrangler.toml with the created resource values:
[[d1_databases]].database_id[[kv_namespaces]].id[[r2_buckets]].bucket_name(e.g.spinupmail-attachments)[[r2_buckets]].preview_bucket_name(e.g.spinupmail-attachments-preview)[vars].EMAIL_DOMAINS(comma-separated inbound domains, can be single domain likespinupmail.comor multiple domains likespinupmail.com,spinupmail.dev)[vars].RESEND_FROM_EMAIL(e.g.Spinupmail <verify@spinupmail.com>. Will be used when sending Verification/Password Reset emails.)- Optional:
[vars].AUTH_ALLOWED_EMAIL_DOMAIN(restrict auth to one email domain. Useful when you want to deploy an internal tool for your organization and restrict access to a specific domain.)[vars].FORCED_MAIL_PREFIX(when set, every created or renamed inbox is forced to start with this prefix plus-, for exampletemp-)[vars].EMAIL_MAX_BYTES[vars].EMAIL_BODY_MAX_BYTES[vars].EMAIL_FORWARD_TO[vars].EMAIL_ATTACHMENT_MAX_BYTES[vars].EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION(default:104857600)[vars].EMAIL_ATTACHMENTS_ENABLED(default:true)[vars].MAX_ADDRESSES_PER_ORGANIZATION(default:100)[vars].MAX_RECEIVED_EMAILS_PER_ORGANIZATION(default:1000)[vars].MAX_RECEIVED_EMAILS_PER_ADDRESS(default:100)[vars].MAX_INTEGRATIONS_PER_ORGANIZATION(default:3)[vars].MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY(default:100)[vars].API_KEY_RATE_LIMIT_WINDOWand[vars].API_KEY_RATE_LIMIT_MAX(default:60seconds and120requests forx-api-keyapp traffic, including Better Auth runtime checks on/get-sessionand/organization/get-full-organization; these apply in addition toAUTH_RATE_LIMIT_*andAUTH_CHANGE_EMAIL_RATE_LIMIT_*)[vars].AUTH_RATE_LIMIT_WINDOW(default:60)[vars].AUTH_RATE_LIMIT_MAX(optional Better Auth global max override)[vars].AUTH_CHANGE_EMAIL_RATE_LIMIT_WINDOW(default:3600)[vars].AUTH_CHANGE_EMAIL_RATE_LIMIT_MAX(default:2)[vars].INTEGRATION_QUEUE_RETRY_WINDOW_SECONDS(default:21600)[vars].INTEGRATION_QUEUE_BASE_DELAY_SECONDS(default:30)[vars].INTEGRATION_QUEUE_MAX_DELAY_SECONDS(default:1800)[vars].INTEGRATION_QUEUE_JITTER_SECONDS(default:10)[vars].EXTENSION_REDIRECT_ORIGINS(comma-separated exact redirect origins for trusted extension builds, for examplehttps://<extension-id>.chromiumapp.org)[vars].EMAIL_STORE_HEADERS_IN_DB[vars].EMAIL_STORE_RAW_IN_DB[vars].EMAIL_STORE_RAW_IN_R2
For local development, create .dev.vars file in packages/backend. Here is a sample file:
BETTER_AUTH_BASE_URL="http://localhost:8787/api/auth"
BETTER_AUTH_SECRET="" # Run `openssl rand -base64 32` to generate, or you can generate from https://better-auth.com/docs/installation
INTEGRATION_SECRET_ENCRYPTION_KEY="" # Run `openssl rand -base64 32` to generate a base64 32-byte key
CORS_ORIGIN="http://localhost:5173,http://127.0.0.1:5173"
EXTENSION_REDIRECT_ORIGINS="https://<your-extension-id>.chromiumapp.org"
RESEND_API_KEY="" # Get from Resend
TURNSTILE_SECRET_KEY="" # Get from Cloudflare
GOOGLE_CLIENT_ID="" # Get from Google Cloud Console
GOOGLE_CLIENT_SECRET="" # Get from Google Cloud ConsoleSet the secrets for the Worker (for Production):
pnpm exec wrangler secret put BETTER_AUTH_BASE_URL
# e.g. https://api.spinupmail.com/api/auth
pnpm exec wrangler secret put BETTER_AUTH_SECRET
# Run `openssl rand -base64 32` to generate a secret, or you can generate from https://better-auth.com/docs/installation
pnpm exec wrangler secret put INTEGRATION_SECRET_ENCRYPTION_KEY
# Run `openssl rand -base64 32` to generate the required base64 32-byte AES-GCM key used to encrypt integration credentials
pnpm exec wrangler secret put CORS_ORIGIN
# e.g. https://app.spinupmail.com
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_SECRET
# See detailed Google OAuth setup instructions belowRun each of these commands in the packages/backend folder and provide the corresponding value when prompted.
Use the Worker URL or your API route URL:
BETTER_AUTH_BASE_URL = https://<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 integration credentials)RESEND_API_KEY = re_...TURNSTILE_SECRET_KEY = <Cloudflare Turnstile secret key>RESEND_FROM_EMAILshould be configured inwrangler.toml[vars]with a verified sender/domain.
- Open Cloudflare dashboard and select your account.
- Use dashboard search and open Turnstile.
- Click Add widget.
- Fill widget configuration:
- Widget name:
Spinupmail(or any name). - Hostname management: add your frontend hostname(s), for example:
localhost(for local frontend)127.0.0.1(optional, for local frontend)your-frontend-domain.com(production)
- Widget mode: Managed.
- Widget name:
- Click Create.
- Copy generated keys:
- Site key (public key)
- Secret key (private key)
- Set backend secret (Worker):
pnpm exec wrangler secret put TURNSTILE_SECRET_KEY- Set frontend env:
- Cloudflare Pages env var:
VITE_TURNSTILE_SITE_KEY=<your site key> - Local dev (
packages/frontend/.env):VITE_TURNSTILE_SITE_KEY=<your site key>
- Cloudflare Pages env var:
Notes:
- Use the secret key only on backend/Worker side (
TURNSTILE_SECRET_KEY). - Use the site key only in frontend (
VITE_TURNSTILE_SITE_KEY). - If Turnstile validation fails in production, confirm your deployed frontend hostname is listed in the widget hostnames.
Spinupmail uses Resend for verification and password-reset emails sent by Better Auth.
- Create or sign in to your Resend account:
- Open
https://resend.com/
- Open
- Add your sending domain in Resend:
- Go to Domains -> Add Domain
- Enter your domain
- Add required DNS records in Cloudflare DNS:
- Click on Auto Configure button to get redirected to Cloudflare.
- Save the records on Cloudflare.
- Wait for domain verification in Resend:
- In Resend Domains, click Verify DNS Records (or wait for auto-check)
- Continue only after status becomes Verified
- Create a Resend API key:
- Go to API Keys -> Create API Key
- Create a new API Key here with a permission to send emails.
- Copy the generated key (
re_...)
- Save API key to backend Worker secret:
pnpm exec wrangler secret put RESEND_API_KEY- Configure sender in
packages/backend/wrangler.toml:- Set
[vars].RESEND_FROM_EMAILto a verified sender on your Resend domain, for example: Spinupmail <verify@mail.your-domain.com>
- Set
- Local development:
- Add
RESEND_API_KEY=...inpackages/backend/.dev.vars
- Add
Notes:
RESEND_API_KEYis a backend secret only; do not expose it in frontend env vars.- The email in
RESEND_FROM_EMAILmust belong to a verified Resend domain, otherwise mail sending will fail.
Spinupmail uses Better Auth social login with Google OAuth.
-
Create a Google Cloud project:
- Open Google Cloud Console:
https://console.cloud.google.com/ - Select or create a project.
- Open Google Cloud Console:
-
Configure OAuth consent screen:
- Go to APIs & Services -> OAuth consent screen
- Click Get Started button
- Fill App Name, this will be shown to users during Google sign-in.
- Choose a User support email
- Choose External user type in Audience selection step
- Fill contact email and save
- On the opened page, click Create OAuth client
-
Create OAuth client credentials:
- On the opened page, click Create OAuth client
- Application type: Web application
- Name:
Spinupmail Auth(or any name you want)
-
Add authorized JavaScript Origins:
http://127.0.0.1:5173http://localhost:5173https://<your-frontend-domain>.com(or your production frontend domain)
-
Add authorized redirect URI(s):
- Local backend:
http://localhost:8787/api/auth/callback/google
- Production backend (pick the one you deploy):
https://<your-api-domain>/api/auth/callback/googlePreferred- or
https://<your-frontend-domain>/api/auth/callback/google(if Worker is routed on the frontend domain under/api/*)
- Local backend:
-
Copy values:
Client ID->GOOGLE_CLIENT_IDClient secret->GOOGLE_CLIENT_SECRET
-
Save credentials to Worker secrets:
pnpm exec wrangler secret put GOOGLE_CLIENT_ID
pnpm exec wrangler secret put GOOGLE_CLIENT_SECRETLocal development with wrangler dev:
- You should also place these in
packages/backend/.dev.vars:GOOGLE_CLIENT_ID=...GOOGLE_CLIENT_SECRET=...
Important:
CORS_ORIGINmust include your frontend origin(s) (for examplehttp://localhost:5173and your production app origin), because Better Auth validates callback URLs against trusted origins.EXTENSION_REDIRECT_ORIGINSmust list the exact extension redirect origins you trust forbrowser.identity.launchWebAuthFlow; wildcard*.chromiumapp.orgor*.extensions.allizom.orgentries are intentionally not supported.- Frontend does not need separate Google env vars for this OAuth redirect flow.
- If you set
AUTH_ALLOWED_EMAIL_DOMAIN, Spinupmail will reject email/password sign-up and sign-in outside that domain and will pass the same domain to Google OAuth using the hosted-domain hint (hd).
Spinupmail includes an integrations platform for routing inbound email events to external notification channels. Telegram is currently supported.
- Create a Telegram bot:
- Open Telegram and chat with
@BotFather - Run
/newbotand follow the prompts - Save the generated bot token
- Open Telegram and chat with
- Collect your target chat ID:
- Send a message to your bot (or add it to a group/channel)
- Open
https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates - Copy the chat id from the updates response
- Ensure
INTEGRATION_SECRET_ENCRYPTION_KEYis configured in Worker secrets. - Create a Telegram integration from your organization settings or integrations API endpoints.
- Attach integration subscriptions to addresses so
email.receivedevents are dispatched to Telegram.
Current event support:
email.received
Integrations are dispatched asynchronously through the integration queue with
retry/backoff controls from INTEGRATION_QUEUE_* vars.
Do not hand-edit migrations.
pnpm -C packages/backend db:generate
pnpm -C packages/backend db:migrate:dev
# for production, run `pnpm -C packages/backend db:migrate:prod`pnpm -C packages/backend deployTo setup automatic deployments:
- Open Build -> Compute -> Workers&Pages section in Cloudflare dashboard
- Click on your Worker (e.g.
spinupmail) - Open Settings tab
- Under Build -> Git Repository section, Click Connect
- Choose the repository (you should fork this repo to your Github account first)
- You can uncheck Builds for non-production branches
- Leave Build command empty
- Fill Deploy command with
pnpm run deploy - Fill Root directory with
packages/backend - You can enable Build Cache if you want
- Click Connect
- Open Build -> Email Service -> Email Routing in Cloudflare dashboard
- Click on Onboard Domain
- Select correct zone and click Done
- Open the added domain in the list
- Open Routing Rules tab and create a Catch-all rule:
- Custom Address: Catch All
- Action: Send to a worker
- Destination: your deployed worker (e.g.
spinupmail)
If you use multiple domains, repeat the routing rule for each domain you add to
EMAIL_DOMAINS and add the domains in the EMAIL_DOMAINS variable in wrangler.toml as comma separated values.
- Open Build -> Compute -> Workers&Pages section in Cloudflare dashboard
- Click on your deployed Worker (e.g.
spinupmail) - Open Settings tab
- In the Domains & Routes section, click Add
- Choose Custom domain
- Enter your API domain (e.g.
api.spinupmail.com). If everything is OK, a DNS preview will show up. Click Add domain.
In packages/backend/wrangler.toml:
[vars]
EMAIL_DOMAINS = "spinupmail.com,spinupmail.dev"
FORCED_MAIL_PREFIX = "temp" # Optional. Forces created/renamed inboxes to start with temp-
AUTH_ALLOWED_EMAIL_DOMAIN = "example.com" # Optional if you want to restrict sign-ups/sign-ins to a domain
MAX_ADDRESSES_PER_ORGANIZATION = "100"
MAX_RECEIVED_EMAILS_PER_ORGANIZATION = "1000"
MAX_RECEIVED_EMAILS_PER_ADDRESS = "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"
MAX_INTEGRATIONS_PER_ORGANIZATION = "3"
MAX_INTEGRATION_DISPATCHES_PER_ORGANIZATION_PER_DAY = "100"
INTEGRATION_QUEUE_RETRY_WINDOW_SECONDS = "21600"
INTEGRATION_QUEUE_BASE_DELAY_SECONDS = "30"
INTEGRATION_QUEUE_MAX_DELAY_SECONDS = "1800"
INTEGRATION_QUEUE_JITTER_SECONDS = "10"
EMAIL_ATTACHMENTS_ENABLED = "true"
EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION = "104857600"
When multiple domains are configured, the UI shows a domain selector during address creation. If only one domain is configured, it is used automatically.
If FORCED_MAIL_PREFIX is configured, the UI also shows the enforced prefix in
the create/edit username field, but the backend remains the source of truth:
every created or renamed address is normalized to start with
<FORCED_MAIL_PREFIX>-, even if a client tries to bypass the UI.
Create a Pages project (not a Worker). The UI can be confusing, explicitly choose "Pages".
- Open Build -> Compute -> Workers&Pages section in Cloudflare dashboard
- Click on Looking to deploy Pages? Get started link at the bottom
- Make sure to fork this repository to you Github account
- Click on Import an existing Git repository
- Connect your Github account and select the forked repository
- In Set up builds and deployments:
- Project name: e.g.
spinupmail - Framework preset: None
- Build command:
pnpm run build - Build output directory:
dist - Root directory -> Path:
packages/frontend - Environment variables:
- VITE_AUTH_BASE_URL -> e.g.
https://api.spinupmail.com/api/auth - VITE_API_BASE_URL -> e.g.
https://api.spinupmail.com - VITE_TURNSTILE_SITE_KEY -> your Cloudflare Turnstile site key
- VITE_AUTH_BASE_URL -> e.g.
Create .env file for frontend development in packages/frontend:
VITE_AUTH_BASE_URL=http://localhost:8787/api/auth
VITE_API_BASE_URL=http://localhost:8787
VITE_TURNSTILE_SITE_KEY=<Your Site Key>After the Pages deployment is successful, set up a custom domain for the frontend:
- Open Custom domains tab in your Pages project dashboard
- Click Set up a custom domain
- Enter your domain (e.g.
app.spinupmail.com) and click Continue - Check the DNS record and click Activate domain
- Wait for the domain to be active (can take a few minutes)
Generate an API key from the UI. Then use it to access the API:
- API key requests must include
X-Org-Idwith an organization the API key owner belongs to. - Session-cookie requests use the active organization from the user session.
Or use the SDK:
pnpm install spinupmailimport { SpinupMail } from "spinupmail";
const spinupmail = new SpinupMail();
const address = await spinupmail.addresses.create({
localPart: "signup-flow",
acceptedRiskNotice: true,
});
const email = await spinupmail.inboxes.waitForEmail({
addressId: address.id,
after: new Date(),
subjectIncludes: "verify",
timeoutMs: 30_000,
});
console.log(email.subject);new SpinupMail() reads:
SPINUPMAIL_API_KEYSPINUPMAIL_BASE_URLorhttps://api.spinupmail.comSPINUPMAIL_ORGANIZATION_IDorSPINUPMAIL_ORG_ID
Use search to match recent emails by indexed content:
const email = await spinupmail.inboxes.waitForEmail({
addressId: address.id,
search: "verify",
timeoutMs: 30_000,
});
const emails = await spinupmail.emails.list({
addressId: address.id,
search: "verify",
});Use after with local filters to wait for a specific email after a specific timestamp:
const startedAt = new Date();
const email = await spinupmail.inboxes.waitForEmail({
addressId: address.id,
after: startedAt,
subjectIncludes: "verify",
bodyIncludes: "654321",
timeoutMs: 30_000,
});
console.log(email.text);curl "https://your-domain.com/api/email-addresses" \
-H "X-API-Key: <your_api_key>" \
-H "X-Org-Id: <organization_id>"curl "https://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 email attachment:
curl -L "https://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.binAttachment handling is part of the inbound email pipeline:
- Email is received by the Worker through Cloudflare Email Routing.
- MIME content is parsed with
postal-mime(including attachments). - Each attachment is validated and uploaded to the
R2_BUCKETbinding under:email-attachments/<organizationId>/<addressId>/<emailId>/<attachmentId>-<filename>
- Metadata is saved in D1 table
email_attachmentswith ownership links (organization_id,user_id,address_id,email_id). /api/emailsand/api/emails/:idinclude attachment metadata for UI/API consumers.- Downloads are served through authenticated endpoint:
GET /api/emails/:id/attachments/:attachmentId- Access is restricted to members of the owning organization (session cookie or API key +
X-Org-Id).
- Raw MIME is not persisted in D1 by default. Optional debug mode can store
raw MIME in private R2 and serve it through:
GET /api/emails/:id/raw- Access is restricted to members of the owning organization (session cookie or API key +
X-Org-Id).
Limits:
EMAIL_MAX_BYTES: max raw email bytes read/parsed by Worker (default524288).EMAIL_BODY_MAX_BYTES: max HTML/text bytes stored per email row in D1 (524288default). Oversized bodies are dropped to avoid DB write failures.EMAIL_ATTACHMENT_MAX_BYTES: max size per attachment uploaded to R2 (default10485760).EMAIL_ATTACHMENT_MAX_TOTAL_BYTES_PER_ORGANIZATION: max total attachment storage across all emails in one organization (default104857600, or 100 MB).EMAIL_ATTACHMENTS_ENABLED: whenfalse, inbound attachments are ignored and attachment UI/API surfaces are disabled (trueby default).MAX_RECEIVED_EMAILS_PER_ORGANIZATION: hard cap across all stored emails in one organization (default1000).MAX_RECEIVED_EMAILS_PER_ADDRESS: hard cap across stored emails in one address (default100).EMAIL_STORE_HEADERS_IN_DB: persist full header JSON in D1 (falseby default).EMAIL_STORE_RAW_IN_DB: persist full raw MIME in D1 (falseby default).EMAIL_STORE_RAW_IN_R2: persist full raw MIME in private R2 (falseby default).
Backend:
pnpm -C packages/backend devFrontend:
pnpm -C packages/frontend devSet .env for frontend local dev if needed:
VITE_AUTH_BASE_URL=http://localhost:8787/api/auth
VITE_API_BASE_URL=http://localhost:8787
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
For local auth, add a packages/backend/.dev.vars
file (not committed):
BETTER_AUTH_SECRET=dev-secret
BETTER_AUTH_BASE_URL=http://localhost:8787/api/auth
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
The frontend dev server proxies /api/* to http://127.0.0.1:8787, so you can
use relative URLs during development.
Cloudflare does not deliver real emails to .workers.dev domains. For local
testing, simulate Email Routing via 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'Make sure to set a unique Message-ID for each test email.
To receive real emails, use a real domain in Cloudflare Email Routing (you
can create a dev subdomain like dev.your-domain.com) and point the routing
rule to your Worker.
SpinupMail uses two separate release tracks:
- Repo releases use tags like
v0.1.0and represent self-hosted SpinupMail releases published on GitHub. - SDK releases use tags like
sdk-v0.1.1and publish the publicspinupmailnpm package frompackages/sdk.
This separation keeps repo releases and npm publishes independent. Creating a
repo tag like v0.1.0 will not publish the SDK. Creating an SDK tag like
sdk-v0.1.1 will not create a product release by itself.
Before creating a release tag, make sure main is green in GitHub Actions. For
a full local pre-release pass, run:
pnpm run test:ciUse repo tags for SpinupMail product releases:
git tag -a v0.1.0 -m "SpinupMail v0.1.0"
git push origin v0.1.0After pushing the tag, create a GitHub Release for that version and summarize
the notable changes from CHANGELOG.md.
Use SDK tags for npm publishes:
- Update
packages/sdk/package.jsonto the new SDK version. - Make sure the SDK release checks pass:
pnpm -C packages/sdk typecheck
pnpm -C packages/sdk test
pnpm -C packages/sdk build
pnpm -C packages/sdk test:package- Create and push the SDK tag:
git tag -a sdk-v0.1.1 -m "spinupmail SDK v0.1.1"
git push origin sdk-v0.1.1The GitHub Actions workflow in .github/workflows/publish-sdk.yml will verify
that the tag matches packages/sdk/package.json and then publish the package
to npm using trusted publishing.