The free, Cloudflare-native alternative to Resend. Transactional email from one API call, with no server to run.
If you use Resend, you already know how this works: an API key, one POST, a 202, then logs, templates and retries in a dashboard. MakiSend gives you the same thing, open source, on your own Cloudflare account. It deploys in one click, even on a Workers Free account, and needs no server, container or database to manage.
Your app sends one authenticated POST /send-email. MakiSend encrypts and queues the message, delivers it through your own SMTP provider with automatic retries, and shows every delivery in a dashboard.
- Free and open source. No per-email fee and no subscription to MakiSend. You pay Cloudflare only if you go past the free plan, and your SMTP provider for the mail it sends (see Running on the Workers Free plan).
- Cloudflare-native. Built on Workers, D1, Queues and Workflows, all of them on the Workers Free plan. Your data stays in your account.
- No server to run. One Worker serves the API and the dashboard; Queues and Workflows handle delivery.
- Bring your own SMTP. Zoho, Gmail, Microsoft 365, Resend, SendGrid, Mailgun or Cloudflare Email Service. You keep your sender domain and reputation.
- Reliable delivery.
202right away, then a durable Workflow with bounded retries and a dead-letter queue. - Inbox, not spam. In our tests, MakiSend emails land in the inbox once SPF, DKIM and DMARC are set on the sender domain.
- Fast enough to feel instant. 300–400 ms from queue to SMTP provider in the best cases, about 2 s in the worst (see Delivery speed).
- Private by default. Recipients, bodies, attachments and SMTP credentials are encrypted with AES-256-GCM before they reach D1.
- Built for teams of one. Per-user API keys, optional send quota, idempotency keys, 2FA, and a dashboard with logs, metrics and templates.
- Feels like Resend. Bearer API key,
Idempotency-Key, and the sameattachmentsformat, so moving a client over is a matter of a few fields (see Coming from Resend).
Status: early. The sending pipeline, dashboard and security basics work end to end. Webhooks, bounce tracking and multi-provider failover are not built yet (see Known limitations and Roadmap).
Further reading: docs/ARCHITECTURE.md (the full system), docs/DEVELOPER_GUIDE.md (run, change, test, debug), AGENTS.md (conventions), DESIGN.md (visual system of the dashboard and the website), CONTRIBUTING.md.
Resend is a hosted email provider: its servers send your mail. MakiSend is the API, queue, retries, logs and dashboard part, running in your Cloudflare account, and it hands each email to an SMTP provider you pick. That can be the mailbox you already pay for (Google Workspace, Zoho, Microsoft 365), Cloudflare Email Service, or Resend's own SMTP if you want to keep its delivery and drop its dashboard.
| Resend | MakiSend | |
|---|---|---|
| Where it runs | Resend's servers | Your Cloudflare account (Workers, D1, Queues, Workflows) |
| Price of the API | Free up to 3,000 emails a month (100 a day), then from $20 a month | Free and open source; Cloudflare's free plan covers hundreds of emails a day |
| Who sends the mail | Resend | Your SMTP provider, or Cloudflare Email Service |
| Your data | Stored by Resend | Encrypted (AES-256-GCM) in your own D1 database |
| Servers to manage | None | None |
Resend figures are from resend.com/pricing, read in October 2026.
Moving a call over:
Resend POST /emails |
MakiSend POST /send-email |
|---|---|
Authorization: Bearer re_… |
Authorization: Bearer mk_… (or x-api-key) |
Idempotency-Key header |
Same header, 24-hour replay window |
from |
Not in the body: the sender address belongs to the SMTP configuration the API key is linked to |
to (string or array) |
One address per request |
subject, html, text |
Same names; text and html are both required |
attachments (filename, content, content_type) |
Same shape; path (a URL to fetch) is refused |
cc, bcc, reply_to, headers, tags, scheduled_at |
Not supported yet |
200 with an id |
202 with an emailId; the outcome shows in the dashboard logs |
A single Cloudflare Worker serves both the dashboard (static Astro build) and the Hono API. Sending is asynchronous.
1. HTTP request: what happens during the call.
flowchart LR
browser["Browser<br/>dashboard"]
client["Client application<br/>API key mk_…"]
subgraph worker["Cloudflare Worker « makisend-poc »"]
assets["Static assets<br/>src/frontend/dist"]
mw["Hono<br/>global middlewares"]
auth["/api/auth/*<br/>Better Auth"]
dash["Dashboard routes<br/>session"]
send["POST /send-email<br/>API key · idempotency<br/>Zod · encryption<br/>→ answers 202"]
end
d1[("D1<br/>makisend_db")]
q[["Queue<br/>send-email"]]
browser -- "pages" --> assets
browser -- "fetch" --> mw
client -- "HTTPS" --> mw
mw --> auth & dash & send
auth & dash --> d1
send -- "pending log<br/>+ encrypted content" --> d1
send -- "{ emailId }" --> q
2. Asynchronous delivery: what happens after the 202.
flowchart TB
q[["Queue<br/>send-email"]]
consumer["queue()<br/>one Workflow per message"]
wf["SendEmailWorkflow<br/>decrypts, cleans the HTML<br/>4 SMTP attempts, 30 s apart"]
d1[("D1<br/>makisend_db")]
smtp["User's SMTP<br/>provider"]
inbox["Recipient's inbox"]
dlq[["DLQ<br/>send-email-dlq"]]
q -- "consumes" --> consumer
consumer -- "create()" --> wf
wf <-- "content · history" --> d1
wf -- "nodemailer" --> smtp
smtp --> inbox
wf -. "permanent failure" .-> dlq
consumer -. "create() failed ×4" .-> dlq
The journey of an email:
- The client calls
POST /send-emailwith its API key. The key determines the user and the SMTP configuration; the request body cannot choose them. - The Worker encrypts the recipient and the content, writes a
pendinglog to D1, queues{ emailId }(no personal data goes through the Queue) and answers202. - The consumer starts one Workflow per email (instance id =
emailId, so a duplicate is rejected). - The Workflow sends over SMTP and appends every step to
email_histories, visible in the dashboard. - Transient error: 3 more attempts, 30 s apart. Permanent error (auth, TLS, DNS, config, 5xx) or exhausted budget: status
failedand DLQ. The consumer itself only retries starting the Workflow (exponential backoff, then DLQ).
Two crons complete the picture: a daily SMTP health check of every configuration and an hourly key rotation job. The full architecture board is makisend-design.excalidraw (open it on excalidraw.com or with the Excalidraw plugin for Obsidian / VS Code).
Stack: Hono, Zod, Drizzle ORM on D1, Queues + Workflows, nodemailer, Better Auth, Pino, Web Crypto (AES-256-GCM), sanitize-html, Vitest; dashboard in Astro + React (shadcn/ui).
makisend/
├── src/backend/ # Cloudflare Worker: one folder per module (controller → service → repository + dto),
│ # drizzle/ migrations, test/ (Vitest unit + Miniflare integration)
├── src/frontend/ # Astro + React dashboard, built into src/frontend/dist
├── docs/ # Architecture, developer guide, updating
└── .github/ # CI and deployment (GitHub Actions)
Prerequisites: Node.js 22+, pnpm, and SMTP credentials from a provider. A Cloudflare account is only needed to deploy: local development is simulated by Miniflare.
pnpm install
cp src/backend/.env.example src/backend/.env # then fill it in (see Configuration)
pnpm --filter ./src/backend dev # API on http://localhost:8787
pnpm --filter ./src/frontend dev # dashboard on http://localhost:4321Or through the root Makefile (a thin wrapper around the same scripts): make setup, make dev, make dev-ui; make help lists every target.
The first connection to the dashboard creates the administrator account. Then create an SMTP configuration and an API key linked to it.
Backend commands, run from src/backend/: pnpm test (watch), pnpm test:run, pnpm test:ui, pnpm test:coverage, pnpm lint, pnpm lint:fix, pnpm cf-typegen. Migrations, end-to-end tests and debugging are in docs/DEVELOPER_GUIDE.md.
Set in src/backend/.env locally, as Worker secrets once deployed:
| Variable | Description |
|---|---|
ENCRYPTION_KEY |
AES-256 key, 64 hex characters (openssl rand -hex 32). Keep a copy: without it, stored data is unreadable. The legacy name AES_256_KEY is still read. Missing or malformed, the dashboard opens a page explaining how to generate one (/encryption-key) |
ENCRYPTION_KEY_PREVIOUS |
Optional, during a key rotation: previous keys, comma-separated (decrypt only) |
BETTER_AUTH_SECRET |
Signs sessions and encrypts the TOTP secrets (changing it breaks existing 2FA set-ups) |
BETTER_AUTH_URL, FRONTEND_URL |
Worker URL and allowed frontend origin. Optional once deployed: the dashboard and the API share one origin, so the request's origin is used |
SEND_RATE_LIMIT |
Optional per-API-key send quota, e.g. 100/min or 5000/day (s, min, hour, day). Over it: 429 with Retry-After. Unset: no quota |
UPDATE_CHECK |
Optional. off stops the daily new-release check (see Updates) |
MAKISEND_REPOSITORY |
Optional. Your copy of the repository (owner/name): the dashboard's Update button opens its sync workflow. The Deploy button pre-fills lixalistudio-oss/makisend, which counts as unset |
UPDATE_SOURCE_REPOSITORY |
Optional. Read releases from another repository. Default lixalistudio-oss/makisend |
SMTP credentials and API keys are not environment variables: they are managed in the dashboard and stored encrypted in D1.
pnpm docker:build
pnpm docker:up # API :8787 (wrangler dev --local), dashboard :4321
pnpm docker:test # also: docker:lint, docker:migrate, docker:downHot reload works on both services, local D1/Queues/Workflows state persists in src/backend/.wrangler, and no secret is copied into the image. Do not run pnpm dev on the host at the same time (port 8787). A devcontainer (.devcontainer/) reuses the same compose file.
Every release publishes a multi-arch (amd64, arm64) image to ghcr.io/lixalistudio-oss/makisend, tagged X.Y.Z, X.Y and latest (a prerelease only gets its own tag). Every green CI run on main also publishes edge (and sha-<commit>), the newest unreleased build; latest only moves on a stable release. One container serves the API and the dashboard; D1 is emulated by Miniflare as a SQLite file in /data, the queue and workflow are emulated too, migrations run at start-up, and a small scheduler fires the daily SMTP check and the hourly key rotation.
docker run -d --name makisend -p 8787:8787 -v makisend-data:/data \
-e ENCRYPTION_KEY=$(openssl rand -hex 32) \
-e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
ghcr.io/lixalistudio-oss/makisend:latestFor a real server (domain, HTTPS with Caddy, updates, backups), follow docs/SELF_HOST_VPS.md and use docker/compose.vps.yml. Keep both secrets (a lost ENCRYPTION_KEY makes stored data unreadable) and the /data volume.
This is a single-instance set-up: not the scalable Cloudflare deployment, and the Cloudflare Email Service configuration is unavailable (use any SMTP provider on 465/587). pnpm test:e2e:docker (in src/backend/) (also run in CI and before each image push) starts the image next to Mailpit and checks a real send, cron and persistence across a restart. The image comes from docker/Dockerfile; the root Dockerfile stays the development image.
The SMTP form offers presets (Zoho Mail, Gmail / Google Workspace, Microsoft 365, Resend, SendGrid, Mailgun, Cloudflare Email Service) that fill the hostname, port and security, and say what the form cannot guess (app password, apikey user name...). A pasted URL (https://smtp.zoho.com:587/) is cleaned into a hostname and a port. Port and TLS mode (tls or starttls) belong to each configuration; port 25 is rejected. Credentials can be checked before saving, and a saved configuration can send a test email, attachments included, from the dashboard.
Cloudflare Email Service (smtp.mx.cloudflare.net): a Worker cannot open an SMTP socket to it, so such a configuration is sent through the Worker's send_email binding (EMAIL), and its user name and password are not used. The sender address must use a domain onboarded to Cloudflare Email Sending.
The Worker serves its own reference: a guide in the dashboard (/dashboard/api-docs), Scalar at /docs and OpenAPI 3.1 at /openapi.json. Request schemas are generated from the Zod DTOs, so they match the actual validation.
| Method | Route | Auth | Description |
|---|---|---|---|
POST |
/send-email |
API key | Queues an email: inline content or templateId; supports Idempotency-Key |
GET/POST |
/smtp-config |
Session | Lists / creates an SMTP configuration |
GET/PUT/DELETE |
/smtp-config/{id} |
Session | Reads / updates / deletes a configuration |
POST |
/smtp-config/check, /smtp-config/{id}/check |
Session | Tests unsaved / saved credentials |
POST |
/smtp-config/{id}/test-send |
Session | Sends a test email (logged as test, never counted in the dashboard totals) |
GET/POST |
/api-keys |
Session | Lists / creates an API key linked to a configuration (several keys can share one) |
DELETE |
/api-keys/{id} |
Session | Revokes an API key |
GET |
/email-logs, /email-logs/{id} |
Session | Paginated logs (filters status, smtpConfigId, apiKeyId, to) / one log and its history |
GET |
/dashboard/stats, /by-config, /by-api-key, /recent, /errors |
Session | Delivery metrics (API sends only) |
GET/POST |
/templates |
Session | Lists / creates a template |
GET/PUT/DELETE |
/templates/{id} |
Session | Reads / updates / deletes a template |
POST |
/templates/{id}/preview |
Session | Renders the template with sample variables |
GET |
/updates/status |
Session | Installed and latest versions (?refresh=true checks now) |
/api/auth/* is served by Better Auth (sign-in, session, sign-out, 2FA).
Send the API key as x-api-key: <KEY> or Authorization: Bearer <KEY>.
{
"to": "recipient@example.com",
"subject": "Email subject",
"text": "Plain-text version of the message.",
"html": "<p>HTML version of the message.</p>",
"attachments": [
{ "filename": "receipt-REC-1.pdf", "content": "JVBERi0xLjQK…", "content_type": "application/pdf" }
]
}| Field | Constraints |
|---|---|
to |
Valid email |
subject |
At least 3 characters |
text |
At least 10 characters |
html |
Not empty; sanitized before sending |
attachments |
Optional. At most 5 files of 512 KB each (decoded). content is standard base64 without line breaks; content_type defaults to application/octet-stream; filename is at most 200 characters, without /, \ or control characters. Resend's path (a URL to fetch) is refused |
Instead of subject/text/html, send templateId and variables: the template is rendered before queueing, and a missing variable answers 400 with the missing names. Mixing both shapes, or sending an unknown field, is rejected. Attachments work with both shapes: they are stored encrypted in D1, one row per file, never go through the queue, and are sent by SMTP and by the Cloudflare send_email binding alike.
Our deployments go only through GitHub Actions (.github/workflows/deploy.yml); nothing is deployed from a local machine.
| Trigger | Effect |
|---|---|
Pull request to main or dev |
Preview Worker makisend-poc-preview, with a D1 database recreated on every deploy and its own -preview queues |
| Push | Remote D1 migrations, queue creation if needed, then deploy of the makisend-poc Worker |
Both jobs run after lint, tests and the frontend build. The Cloudflare configuration is src/backend/wrangler.jsonc. GitHub secrets: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, AES_256_KEY (preview), BETTER_AUTH_SECRET. The production Worker reads its own Cloudflare secrets (see Configuration); the job checks that they exist.
The button at the top of this page first opens makisend.lixalistudio.tech/deploy, which explains the two secrets Cloudflare will ask for and how to generate them: ENCRYPTION_KEY (openssl rand -hex 32, 64 hex characters, keep a copy) and BETTER_AUTH_SECRET (openssl rand -base64 32). It can also generate them in your browser. Continue to Cloudflare then opens Cloudflare's flow (direct link): it copies the repository into your GitHub (or GitLab) account, lets you name the Worker, the D1 database and the queues, asks for the two secrets (paste each value into the field with the same name), then creates the resources and deploys with Workers Builds. Open the Worker's URL to create the administrator account, turn on 2FA and, if the API is reachable from the internet, set SEND_RATE_LIMIT.
The button uses the root wrangler.jsonc and package.json; a test keeps them in sync with src/backend/wrangler.jsonc (details in AGENTS.md).
The dashboard shows a banner when a newer release is published, and Settings → MakiSend version shows the installed and latest versions. The Worker reads the latest GitHub release once a day (unauthenticated, nothing about the instance is sent). Installing an update means merging the release into your copy, which redeploys it: run the Sync with upstream workflow shipped in every copy, or merge by hand. Guide, rollback and how releases are cut: docs/UPDATING.md.
docs/AGENT_SELF_HOST.md is a prompt for a coding agent: it creates the resources on your Cloudflare account with the cf CLI, sets the secrets, deploys and applies the security practices below.
- API keys per user (Better Auth):
401without a key,403for an invalid one. Dashboard routes use the Better Auth session. - Encryption at rest: SMTP credentials, recipients, bodies and attachments (AES-256-GCM). SMTP passwords are never returned by the API.
- Key rotation: copy the current key into
ENCRYPTION_KEY_PREVIOUSand set a newENCRYPTION_KEY; the hourly cron re-encrypts everything and logsremaining. Once it logsremaining: 0andfailed: 0, deleteENCRYPTION_KEY_PREVIOUS. Never replace the key outright while the database holds data. - One account: the first connection (
/setup) creates the only account; every later creation is refused (403), atomically, on every creation path. Deleting the account makes the instance configurable again. - Two-factor authentication: TOTP from Settings, with backup codes and an optional 30-day trusted device. Once it is on, the account can opt in to sign in with email + code only (off by default, one use per code, 10 wrong codes lock the account for 15 minutes).
- Inputs and transport: Zod
.strict()validation, HTML cleaned withsanitize-html, verified TLS (rejectUnauthorized: true).
Limitations:
- Send quota is opt-in. Sign-in attempts are always rate limited per IP, but without
SEND_RATE_LIMITa leaked key can send until it is revoked. Set it on any instance whose API is reachable from the internet:cf workers secrets update SEND_RATE_LIMIT --worker <worker> --type secret_text --text "100/min". - No API key scopes or automatic rotation, no password reset by email, no webhook signatures, no audit log beyond the application logs.
POST /send-email answers 202 in milliseconds; the send happens right after, in the Workflow. From there to the provider accepting the message:
| Case | Time to the SMTP provider |
|---|---|
| Best case | 300–400 ms |
| Worst case | about 2 s |
Most of that time is the SMTP protocol, not MakiSend. SMTP waits for a reply to each command before sending the next (RFC 5321), and on high-latency links this turnaround time dominates (RFC 2920). One send is a chain of round trips (TCP connect, TLS handshake, EHLO, AUTH, MAIL FROM, RCPT TO, DATA, the message, QUIT), and a Worker opens a fresh connection for every email. Commonly cited figures put an SMTP send at 200 ms to 1 s, against roughly 100–500 ms for an HTTP email API (comparison). Delivery to the inbox then depends on the receiving server.
Everything MakiSend uses (Workers, static assets, D1, Queues, Workflows, cron triggers) is part of Cloudflare's Workers Free plan, so the Deploy button works on a free account and nothing is billed. What the free plan sets is a daily volume. The tightest quota is Workflows steps: a delivered email runs 3 steps (prepare, send, record), and every SMTP retry adds one.
| Free plan quota (per day) | What it means for MakiSend |
|---|---|
| Workflows: 3,000 steps | Hundreds of delivered emails a day, the binding limit (3 steps per email, one more per SMTP retry) |
| Queues: 10,000 operations | Not the limit: 3 operations per email (write, read, delete) |
| Workers: 100,000 requests, shared with Workflow runs | API calls, dashboard and deliveries together |
| D1: 100,000 rows written, 5 GB stored | Logs, history and encrypted bodies |
| Cron triggers: 5 per account | MakiSend uses 2 (health check, key rotation) |
Limits from Cloudflare's Workflows, Queues, D1 and Workers pages.
Two things the free plan does not give you:
- Cloudflare Email Service only sends to arbitrary recipients on Workers Paid. On a free account, use another SMTP provider (whose own sending limits also apply: Gmail and Google Workspace cap a mailbox per day).
- CPU time is capped at 10 ms per invocation and per Workflow step. MakiSend's steps mostly wait on the network, which does not count, but very large HTML bodies take longer to sanitize.
For volume, move the account to Workers Paid ($5 a month). It includes 500,000 Workflows steps a month and 1 million Queues operations; past that, steps cost $0.80 per 100,000, with no daily cap. We have not measured a real volume yet, so we give no per-email figure. Nothing changes in MakiSend: same Worker, same configuration.
- No fixed outbound IP: Workers connect from shared, changing Cloudflare IPs, so a provider that requires IP allow-listing will refuse sends. Disable allow-listing and rely on the SMTP credentials, or run the service on a server with a fixed IP.
- Asynchronous only: the outcome of a send is visible in the dashboard logs, not in the
202response. - One SMTP provider per configuration: no multi-provider routing or failover.
- No tracking: no bounce, open or click tracking; only send logs.
- D1 is serverless SQLite: possible latency, not suited to very high volumes without caching.
- Deliverability: SPF, DKIM, DMARC and IP reputation depend on the SMTP provider and the sender domain.
Available today: everything above (API-key sending, async pipeline with retries and DLQ, per-configuration SMTP, templates with the React Email builder or pasted TSX, encrypted logs, idempotency, health checks, dashboard metrics, OpenAPI docs, CI and deployment).
Next:
- Scale and throughput tuning of the queue and workflow pipeline
- Deliverability improvements (anti-spam, SPF/DKIM/DMARC)