
MailySend
Self-hosted transactional email APIs, queues and delivery management
MailySend is a self-hosted Postmark/Resend alternative built on Cloudflare (Analytics Engine, D1, Durable Objects, KV, Queues). Paid services required. Inspect the source and license in the linked repository.
Source & license
Upstream license: MIT
License TL;DR
You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.
Explain MIT in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Paid services required
The Cloudflare Email Service path requires Workers Paid from $5 USD/account/month, includes 3,000 sends/month, then bills $0.35/1,000. Storage, queues and DO overages are additional. Analytics Engine is currently not billed, per its primary pricing page. SES, Resend and SMTP are alternative transports with separate provider fees; SMTP ingress requires an optional external shim.
Hosting requirements
- Configure an owned sending domain and actual transport entitlement; API acceptance does not guarantee inbox delivery.
- The optional SMTP-ingress shim is outside the Workers HTTP API core and has its own hosting cost. Budget quotas for both send and tracking Workers.
- Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested.
Sources checked 01/10/2026
Repository snapshot: 3708d2f. Hosting eligibility reflects the deployment documentation and listed assumptions.
- resend ↗
The complete email platform — transactional sending, marketing broadcasts, automations, inbound mail with threading and deliverability analytics — running entirely on Cloudflare Workers, **in your own account**. Drop-in compatible with the Resend API. MIT licensed. Also runs on a plain Node server with no Cloudflare account at all.
- postmark ↗
The complete email platform — transactional sending, marketing broadcasts, automations, inbound mail with threading and deliverability analytics — running entirely on Cloudflare Workers, **in your own account**. Drop-in compatible with the Resend API. MIT licensed. Also runs on a plain Node server with no Cloudflare account at all.
- workers ↗
// R2, Secrets Store) or be created by scripts/provision.ts before the deploy. // Queues, Workflows, Analytics Engine datasets and the sending domain do NOT // auto-provision — that is why the button's real path is repo -> CI -> deploy. "$schema": "node_modules/wrangler/config-schema.json", "name": "mailysend", // The *source* entry, not a build artifact. The Cloudflare Vite plugin reads // this config at config-resolution time — before anything is built — so a // path into the outp
- d1 ↗
real ids into the config wrangler // deploys, and by wrangler's own provisioning if that could not run. "d1_databases": [ { "binding": "DB", "database_name": "mailysend", "migrations_dir": "../../packages/db/migrations" } ], "kv_namespaces": [ { "binding": "CACHE" }, // Separate from CACHE deliberately: suppressions are permanent and // authoritative-for-reads, and must never share an eviction story with a // 300-second API-key cache. { "bindi
- kv ↗
: "DB", "database_name": "mailysend", "migrations_dir": "../../packages/db/migrations" } ], "kv_namespaces": [ { "binding": "CACHE" }, // Separate from CACHE deliberately: suppressions are permanent and // authoritative-for-reads, and must never share an eviction story with a // 300-second API-key cache. { "binding": "SUPPRESSIONS" } ], "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "mailysend" }], "queues": { "producers": [ { "binding"
- r2 ↗
and must never share an eviction story with a // 300-second API-key cache. { "binding": "SUPPRESSIONS" } ], "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "mailysend" }], "queues": { "producers": [ { "binding": "SEND_QUEUE", "queue": "ms-send" }, { "binding": "SEND_BULK_QUEUE", "queue": "ms-send-bulk" }, { "binding": "EVENTS_QUEUE", "queue": "ms-events-norm" }, { "binding": "WEBHOOKS_QUEUE", "queue": "ms-webhooks" }, { "binding": "BROADCAST_QUEUE
- durable-objects ↗
t": 30, "max_retries": 3, "dead_letter_queue": "ms-dlq" } ] }, "durable_objects": { "bindings": [ { "name": "SENDING_DOMAIN", "class_name": "SendingDomainDO" }, { "name": "BROADCAST", "class_name": "BroadcastDO" }, { "name": "BROADCAST_COUNTER", "class_name": "BroadcastCounterDO" }, { "name": "WEBHOOK_ENDPOINT", "class_name": "WebhookEndpointDO" }, { "name": "SCHEDULE_SHARD", "class_name": "ScheduleShardDO" }, { "name": "MAILBOX", "class_n
- queues ↗
: "SUPPRESSIONS" } ], "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "mailysend" }], "queues": { "producers": [ { "binding": "SEND_QUEUE", "queue": "ms-send" }, { "binding": "SEND_BULK_QUEUE", "queue": "ms-send-bulk" }, { "binding": "EVENTS_QUEUE", "queue": "ms-events-norm" }, { "binding": "WEBHOOKS_QUEUE", "queue": "ms-webhooks" }, { "binding": "BROADCAST_QUEUE", "queue": "ms-broadcast-pages" }, { "binding": "INBOUND_QUEUE", "queue": "ms-inbound" },
- analytics-engine ↗
"AutomationCohortDO", "AutomationRunDO", "WorkspaceHubDO" ] } ], "analytics_engine_datasets": [ { "binding": "EMAIL_EVENTS", "dataset": "ms_email_events" }, { "binding": "API_USAGE", "dataset": "ms_api_usage" } ], "send_email": [{ "name": "SEND_EMAIL" }], // One schedule, deliberately. Cron triggers are counted per account and the // Workers Free plan allows five, so three per instance meant the second // deployment on an account lost triggers and the
- paid ↗
{ // The self-hosted deployment. This is the file the "Deploy to Cloudflare" // button reads, so every binding it names must either auto-provision (KV, D1, // R2, Secrets Store) or be created by scripts/provision.ts before the deploy. // Queues, Workflows, Analytics Engine datasets and the sending domain do NOT // auto-provision — that is why the button's real path is repo -> CI -> deploy. "$schema": "node_modules/wrangler/config-schema.json", "name": "mailysend", // The *source* entry, not a build artifact. The Cloudflare Vite plugin reads // this config at config-resolution time — before anything is built — so a // path into the output directory fails on a clean checkout, and the plugin // is what produces the bundle anyway. "main": "src/server.ts", "compatibili
- paid ↗
The Workers Paid plan includes Workers, Pages Functions, Workers KV, Hyperdrive, and Durable Objects usage for a minimum charge of $5 USD per month for an account. The plan includes increased initial usage allotments, with clear charges for usage that exceeds the base plan. There are no additional charges for data transfer (egress) or throughput (bandwidth).
- paid ↗
| Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows |
- paid ↗
| Keys read | 100,000 / day | 10 million/month, + $0.50/million |
- paid ↗
| Storage | 10 GB-month / month |
- paid ↗
Durable Objects are available both on Workers Free and Workers Paid plans.
- paid ↗
| Standard operations | 10,000 operations/day included | 1,000,000 operations/month included + $0.40/million operations |
- paid ↗
| **Workers Paid** | 10 million included per month <br> (+$0.25 per additional million) | 1 million included per month (+$1.00 per additional million) |
- paid ↗
| **Outbound emails (Email Sending)** | Not available | 3,000 included per month, then $0.35 per 1,000 emails |
- paid ↗
Currently, you will not be billed for your use of Workers Analytics Engine. Pricing information here is shared in advance, so that you can estimate what your costs will be once Cloudflare starts billing for usage in the coming months.
- paid ↗
| Class A Operations | 1 million requests / month |
- paid ↗
| Class B Operations | 10 million requests / month |
- paid ↗
| Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows |
- paid ↗
| Keys written | 1,000 / day | 1 million/month, + $5.00/million |
- paid ↗
| SQL Stored data <sup>5</sup> | 5 GB (total) | 5 GB-month, + $0.20/ GB-month |
- MIT ↗
MIT License Copyright (c) 2026 MailySend contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
- architecture ↗
{ // The self-hosted deployment. This is the file the "Deploy to Cloudflare" // button reads, so every binding it names must either auto-provision (KV, D1, // R2, Secrets Store) or be created by scripts/provision.ts before the deploy. // Queues, Workflows, Analytics Engine datasets and the sending domain do NOT // auto-provision — that is why the button's real path is repo -> CI -> deploy. "$schema": "node_modules/wrangler/config-schema.json", "name": "mailysend", // The *source* entry, not a build artifact. The Cloudflare Vite plugin reads // this config at config-resolution time — before anything is built — so a // path into the output directory fails on a clean checkout, and the plugin // is what produces the bundle anyway. "main": "src/server.ts", "compatibili
- architecture ↗
{ // The tracking Worker. No D1 binding, on purpose: the token is // self-validating, so this stays a tiny bundle with an invisible cold start // on the path of every image load in every message ever sent. "$schema": "../../node_modules/wrangler/config-schema.json", "name": "mailysend-track", "main": "src/index.ts", "compatibility_date": "2026-09-01", "observability": { "enabled": true, "head_sampling_rate": 0.01 }, // Set this to your own deployment before deploying the tracking Worker: it is // where an expired or unrecognised token redirects, and the origin the /u/* // preference routes are forwarded to. This Worker is optional — the app // serves /o, /c and /u itself unless you split them out. "vars": { "MS_PUBLIC_URL": "https://mailysend.example.com" }, //
- architecture ↗
// R2, Secrets Store) or be created by scripts/provision.ts before the deploy. // Queues, Workflows, Analytics Engine datasets and the sending domain do NOT // auto-provision — that is why the button's real path is repo -> CI -> deploy. "$schema": "node_modules/wrangler/config-schema.json", "name": "mailysend", // The *source* entry, not a build artifact. The Cloudflare Vite plugin reads // this config at config-resolution time — before anything is built — so a // path into the outp
- architecture ↗
real ids into the config wrangler // deploys, and by wrangler's own provisioning if that could not run. "d1_databases": [ { "binding": "DB", "database_name": "mailysend", "migrations_dir": "../../packages/db/migrations" } ], "kv_namespaces": [ { "binding": "CACHE" }, // Separate from CACHE deliberately: suppressions are permanent and // authoritative-for-reads, and must never share an eviction story with a // 300-second API-key cache. { "bindi
- architecture ↗
: "DB", "database_name": "mailysend", "migrations_dir": "../../packages/db/migrations" } ], "kv_namespaces": [ { "binding": "CACHE" }, // Separate from CACHE deliberately: suppressions are permanent and // authoritative-for-reads, and must never share an eviction story with a // 300-second API-key cache. { "binding": "SUPPRESSIONS" } ], "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "mailysend" }], "queues": { "producers": [ { "binding"
- architecture ↗
and must never share an eviction story with a // 300-second API-key cache. { "binding": "SUPPRESSIONS" } ], "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "mailysend" }], "queues": { "producers": [ { "binding": "SEND_QUEUE", "queue": "ms-send" }, { "binding": "SEND_BULK_QUEUE", "queue": "ms-send-bulk" }, { "binding": "EVENTS_QUEUE", "queue": "ms-events-norm" }, { "binding": "WEBHOOKS_QUEUE", "queue": "ms-webhooks" }, { "binding": "BROADCAST_QUEUE
- architecture ↗
t": 30, "max_retries": 3, "dead_letter_queue": "ms-dlq" } ] }, "durable_objects": { "bindings": [ { "name": "SENDING_DOMAIN", "class_name": "SendingDomainDO" }, { "name": "BROADCAST", "class_name": "BroadcastDO" }, { "name": "BROADCAST_COUNTER", "class_name": "BroadcastCounterDO" }, { "name": "WEBHOOK_ENDPOINT", "class_name": "WebhookEndpointDO" }, { "name": "SCHEDULE_SHARD", "class_name": "ScheduleShardDO" }, { "name": "MAILBOX", "class_n
- architecture ↗
: "SUPPRESSIONS" } ], "r2_buckets": [{ "binding": "BUCKET", "bucket_name": "mailysend" }], "queues": { "producers": [ { "binding": "SEND_QUEUE", "queue": "ms-send" }, { "binding": "SEND_BULK_QUEUE", "queue": "ms-send-bulk" }, { "binding": "EVENTS_QUEUE", "queue": "ms-events-norm" }, { "binding": "WEBHOOKS_QUEUE", "queue": "ms-webhooks" }, { "binding": "BROADCAST_QUEUE", "queue": "ms-broadcast-pages" }, { "binding": "INBOUND_QUEUE", "queue": "ms-inbound" },
- architecture ↗
"AutomationCohortDO", "AutomationRunDO", "WorkspaceHubDO" ] } ], "analytics_engine_datasets": [ { "binding": "EMAIL_EVENTS", "dataset": "ms_email_events" }, { "binding": "API_USAGE", "dataset": "ms_api_usage" } ], "send_email": [{ "name": "SEND_EMAIL" }], // One schedule, deliberately. Cron triggers are counted per account and the // Workers Free plan allows five, so three per instance meant the second // deployment on an account lost triggers and the
Upstream screenshot · GagnDeep/mailysend repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Transactional send APIs, queued delivery and delivery-event management; upstream Resend API-compatibility claims have not been runtime-verified, and no full platform parity is asserted.
See supporting source ↗Transactional send APIs, queued delivery and delivery-event management; upstream Resend API-compatibility claims have not been runtime-verified, and no full platform parity is asserted.
See supporting source ↗How it works
The shape of MailySend on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit 3708d2f6fdfb. Files were read as data; upstream applications and CI jobs were not executed.
Partial source coverage: 30 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.
Deployment configuration · 2 files
Cloudflare Workers · compatibility 2026-09-01
mailysend · default
Entrypoint: src/server.ts
Static assets: directory not declared · Worker first: ["/v1/*","/o/*","/c/*","/u/*","/mcp/*"]
Cron triggers (UTC): * * * * *
DB→ D1CACHE→ KVSUPPRESSIONS→ KVBUCKET→ R2SENDING_DOMAIN→ Durable Objects · class SendingDomainDOBROADCAST→ Durable Objects · class BroadcastDOBROADCAST_COUNTER→ Durable Objects · class BroadcastCounterDOWEBHOOK_ENDPOINT→ Durable Objects · class WebhookEndpointDOSCHEDULE_SHARD→ Durable Objects · class ScheduleShardDOMAILBOX→ Durable Objects · class MailboxDOSEGMENT→ Durable Objects · class SegmentDOAUTOMATION_COHORT→ Durable Objects · class AutomationCohortDOAUTOMATION_RUN→ Durable Objects · class AutomationRunDOWORKSPACE_HUB→ Durable Objects · class WorkspaceHubDOEMAIL_EVENTS→ Analytics EngineAPI_USAGE→ Analytics EngineSEND_QUEUE→ Queues (producer) · queue ms-sendSEND_BULK_QUEUE→ Queues (producer) · queue ms-send-bulkEVENTS_QUEUE→ Queues (producer) · queue ms-events-normWEBHOOKS_QUEUE→ Queues (producer) · queue ms-webhooksBROADCAST_QUEUE→ Queues (producer) · queue ms-broadcast-pagesINBOUND_QUEUE→ Queues (producer) · queue ms-inboundSEGMENTS_QUEUE→ Queues (producer) · queue ms-segmentsAUTOMATION_QUEUE→ Queues (producer) · queue ms-automation-triggersDMARC_QUEUE→ Queues (producer) · queue ms-dmarcEXPORT_QUEUE→ Queues (producer) · queue ms-exportms-send→ Queues (consumer) · queue ms-send · dead letters ms-dlqms-send-bulk→ Queues (consumer) · queue ms-send-bulk · dead letters ms-dlqms-events-cf→ Queues (consumer) · queue ms-events-cf · dead letters ms-dlqms-events-norm→ Queues (consumer) · queue ms-events-norm · dead letters ms-dlqms-webhooks→ Queues (consumer) · queue ms-webhooks · dead letters ms-dlqms-broadcast-pages→ Queues (consumer) · queue ms-broadcast-pages · dead letters ms-dlqms-inbound→ Queues (consumer) · queue ms-inbound · dead letters ms-dlqms-segments→ Queues (consumer) · queue ms-segments · dead letters ms-dlqms-automation-triggers→ Queues (consumer) · queue ms-automation-triggers · dead letters ms-dlqms-dmarc→ Queues (consumer) · queue ms-dmarc · dead letters ms-dlqms-export→ Queues (consumer) · queue ms-export · dead letters ms-dlqSEND_EMAIL→ Send EmailASSETS→ Static assets
Cloudflare Workers · compatibility 2026-09-01
mailysend-track · default
Entrypoint: src/index.ts
CACHE→ KVEVENTS_QUEUE→ Queues (producer) · queue ms-events-norm
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L151 · fetch handler exported · calls resolve, configure, runWithEnv, route
- L168 · queue handler exported · calls configure, runWithEnv, queueRole, consumeSend, consumeEventQueue, consumeWebhooks, consumeBroadcastPages, consumeInbound, consumeMisc
- L197 · email handler exported · calls configure, runWithEnv, handleInboundEmail
- L202 · scheduled handler exported · calls configure, runWithEnv, runCron
- L36 · route calls (conditional paths may differ): pathname.startsWith, handleOpen, pathname.slice, handleClick, handleUnsubscribe, handleLiveSocket, api.fetch, db, tenancyFor, isClaimed, redirect, actorFromSession, includes, request.headers.get, encodeURIComponent, response.headers.get, handleMcp, Scalar, startHandler
- L135 · resolve calls (conditional paths may differ): runtime.startNodeRuntime
Environment references: env.MS_LANDING · env.DB
- L40 · fetch handler exported · references MS_SECRET, EVENTS_QUEUE, MS_PUBLIC_URL, CACHE · calls pathname.startsWith, verifyTrackingToken, replace, pathname.slice, pixel, classifyHit, request.headers.get, ctx.waitUntil, env.EVENTS_QUEUE.send, trackingEventId, String, toISOString, slice, Response.redirect, env.CACHE.get, kvKey.link, destination.slice
Environment references: env.MS_SECRET · env.EVENTS_QUEUE · env.MS_PUBLIC_URL · env.CACHE
- L34 · bearerToken calls (conditional paths may differ): request.headers.get, header.split, scheme.toLowerCase, token.trim
- L42 · actorFromApiKey calls (conditional paths may differ): test, apiError, hashApiKey, kvKey.apiKey, cache.get, first, bind, sql.prepare, Date.now, cache.put, JSON.stringify, Date.parse, token.startsWith
- L85 · actorFromSession calls (conditional paths may differ): request.headers.get, exec, decodeURIComponent, first, bind, sql.prepare, hashApiKey, Date.parse, Date.now
- L129 · requireScope calls (conditional paths may differ): actor.scopes.includes, apiError
- L139 · requireRole calls (conditional paths may differ): apiError
- L73 · instanceValue calls (conditional paths may differ): memoFor, memo.get, first, bind, sql.prepare, read, memo.set, run, make, toISOString
- L112 · ensureInstance calls (conditional paths may differ): readies.get, migrate, ensureWorkspace, adoptExistingOwner, readies.set
- L125 · ensureWorkspace calls (conditional paths may differ): first, bind, env.DB.prepare, toISOString, run, generateApiKey, newId, hashApiKey, apiKeyPreview, requireClaimCode, claimCodeString, console.log
- L281 · adoptExistingOwner calls (conditional paths may differ): isClaimed, first, env.DB.prepare, claimInstance
- L304 · configure calls (conditional paths may differ): ensureInstance, instanceValue, hex, pinnedPublicUrl, get, memoFor, storedPublicUrl, isLocal, isVanityRegression, isUrlPinned, toISOString, bind, env.DB.prepare, statements.push, env.DB.batch, set, adaptActors, Object.assign
- L420 · adaptActors calls (conditional paths may differ): actorNamespace
- L492 · readInstanceSetting calls (conditional paths may differ): first, bind, sql.prepare
- L500 · writeInstanceSetting calls (conditional paths may differ): run, bind, sql.prepare, toISOString, delete, memoFor
- L521 · claimInstance calls (conditional paths may differ): toISOString, run, bind, sql.prepare, readInstanceSetting
Environment references: env.DB · env.MS_REQUIRE_CLAIM_CODE · env.MS_OWNER_EMAIL · env.MS_SECRET · env.MS_MODE · env.MS_LANDING · env.MS_PUBLIC_URL
- L20 · consumeBroadcastPages calls (conditional paths may differ): env.CACHE.get, kvKey.broadcastFlag, message.retry, message.ack, sendPage, env.BROADCAST.get, doName, coordinator.advance, console.error
- L48 · sendPage calls (conditional paths may differ): db, tenancyFor, buildContext, first, bind, sql.prepare, all, Math.min, r2Key.broadcastBody, env.BUCKET.get, body.json, run, toISOString, acceptEmail, console.error, env.BROADCAST_COUNTER.get, doName, counter.increment, contacts.at
Environment references: env.CACHE · env.BROADCAST · env.BUCKET · env.BROADCAST_COUNTER
- L60 · consumeEventQueue calls (conditional paths may differ): normalize, byWorkspace.get, list.push, byWorkspace.set, message.ack, console.error, env.BUCKET.put, r2Key.deadLetter, Date.now, JSON.stringify, String, message.retry, db, tenancyFor, consumeEvents, fanOut, filter, events.map, run, bind
- L133 · normalize calls (conditional paths may differ): isCloudflareSubscriptionEvent, workspaceForCloudflareDomain, flattenCloudflareEvent, emailIdForProviderMessage, normalizeCloudflareEvent, normalizeSesNotification, extractSesEmailId, normalizeResendEvent, extractResendEmailId, normalizeDsn, parseDsn, toISOString
- L177 · isCloudflareSubscriptionEvent calls (conditional paths may differ): type.startsWith
- L187 · flattenCloudflareEvent calls (conditional paths may differ): event.type.slice
- L204 · workspaceForCloudflareDomain calls (conditional paths may differ): first, bind, prepare, db, tenancyFor, domain.toLowerCase
- L215 · emailIdForProviderMessage calls (conditional paths may differ): first, bind, prepare, db, tenancyFor
- L239 · fanOut calls (conditional paths may differ): db, tenancyFor, all, bind, sql.prepare, JSON.parse, subscribed.includes, jobs.push, env.WEBHOOKS_QUEUE.sendBatch
- L278 · stageForArchive calls (conditional paths may differ): hourKey, String, stableBucket, r2Key.eventStage, Date.now, env.BUCKET.put, join, events.map, JSON.stringify
Environment references: env.BUCKET · env.CACHE · env.EMAIL_EVENTS · env.EVENT_DETAIL · env.WORKSPACE_HUB · env.MS_MODE · env.WEBHOOKS_QUEUE
- L49 · consumeInbound calls (conditional paths may differ): handleInbound, message.ack, console.error, message.retry, recordRawOnly
- L68 · handleInbound calls (conditional paths may differ): db, tenancyFor, first, bind, sql.prepare, resolveMailbox, console.warn, recordInboundReject, env.BUCKET.get, recordRawOnly, object.arrayBuffer, PostalMime.parse, extractReplyToken, normalizeSubject, filter, map, parseReferences, env.MAILBOX.get, doName, actor.resolveThread
- L316 · recordRawOnly calls (conditional paths may differ): db, tenancyFor, run, bind, sql.prepare, JSON.stringify, slice, String, newId, catch, writeMailMessage, console.warn
- L383 · extractReplyToken calls (conditional paths may differ): address.split, local.slice, verifyReplyToken
- L416 · recordInboundReceived calls (conditional paths may differ): run, bind, sql.prepare, sha256Hex, mailboxAddress.split, toISOString, console.error
- L456 · forwardToWebhook calls (conditional paths may differ): first, bind, sql.prepare, console.warn, env.WEBHOOKS_QUEUE.send, newId, toISOString, summary.snippet.slice, console.error
Environment references: env.BUCKET · env.MAILBOX · env.AUTOMATION_QUEUE · env.WORKSPACE_HUB · env.MS_SECRET · env.WEBHOOKS_QUEUE
- L58 · consumeWebhooks calls (conditional paths may differ): Promise.all, batch.messages.map, normalizeJob, db, tenancyFor, first, bind, sql.prepare, message.ack, newId, deliver, recordAttempt, run, toISOString, message.retry, Math.min, env.WEBHOOK_ENDPOINT.get, doName, actor.enqueueTail
- L140 · deliver calls (conditional paths may differ): JSON.stringify, signWebhook, Date.now, fetch, AbortSignal.timeout, slice, catch, response.text, String
- L179 · recordAttempt calls (conditional paths may differ): run, bind, sql.prepare, toISOString
Environment references: env.WEBHOOK_ENDPOINT
- L59 · buildContext calls (conditional paths may differ): tenancyFor, tenancy.resolve, resolveFeatures, tenancy.db, env.MS_PUBLIC_URL.replace, replace
Environment references: env.DB · env.CACHE · env.MS_MODE · env.EVENT_DETAIL · env.SUPPRESSIONS · env.BUCKET · env.MS_PUBLIC_URL · env.MS_TRACKING_URL
- L34 · runCron calls (conditional paths may differ): sweepExpired, claimDailyRun, dailyMaintenance
- L57 · claimDailyRun calls (conditional paths may differ): now.getUTCHours, slice, now.toISOString, db, tenancyFor, run, bind, sql.prepare
- L85 · sweepExpired calls (conditional paths may differ): db, tenancyFor, toISOString, catch, reclaimStuckSends, console.error, sql.batch, bind, sql.prepare, Date.now
- L130 · dailyMaintenance calls (conditional paths may differ): db, tenancyFor, first, bind, sql.prepare, Number, Number.isFinite, toISOString, Date.now, run, sql.batch, env.EXPORT_QUEUE.send, monthKey, replace, r2Key.eventStage
Environment references: env.EXPORT_QUEUE
- L34 · resolveWorkspace calls (conditional paths may differ): recipient.split, db, tenancyFor, first, bind, sql.prepare
- L55 · parseAuthResults calls (conditional paths may differ): exec, read
- L80 · resolveMailbox calls (conditional paths may differ): recipient.toLowerCase, first, bind, sql.prepare, to.split
- L131 · adoptDomain calls (conditional paths may differ): recipient.split, first, bind, sql.prepare, run, newId, toISOString, console.log
- L210 · withKnownLength calls (conditional paths may differ): raw.pipeThrough
- L225 · handleInboundEmail calls (conditional paths may differ): message.to.toLowerCase, resolveWorkspace, console.warn, message.setReject, db, tenancyFor, resolveMailbox, adoptDomain, to.split, recordInboundReject, newId, r2Key.rawInbound, env.BUCKET.put, withKnownLength, String, env.INBOUND_QUEUE.send, parseAuthResults, message.headers.get, toISOString
- L291 · recordInboundReject calls (conditional paths may differ): toISOString, run, bind, sql.prepare, sha256Hex, to.split, console.error
Environment references: env.MS_MODE · env.BUCKET · env.INBOUND_QUEUE
- L51 · consumeSend calls (conditional paths may differ): handleOne, message.ack, Math.min, console.warn, String, message.retry, markFailed
- L91 · deliverNow calls (conditional paths may differ): handleOne, console.warn, describe, markFailed
- L132 · reclaimStuckSends calls (conditional paths may differ): db, tenancyFor, Date.now, all, bind, sql.prepare, run, r2Key.spool, env.BUCKET.get, deliverNow, markFailed
- L175 · handleOne calls (conditional paths may differ): db, tenancyFor, Date.now, run, bind, sql.prepare, console.log, attempt, catch
- L222 · attempt calls (conditional paths may differ): loadEnvelope, buildOutbound, env.SENDING_DOMAIN.get, doName, governor.reserve, Math.ceil, recordSent, toISOString, catch, deliverLoopback, console.warn, buildRouter, router.providers.some, router.send, governor.recordSuccess, governor.recordQuotaExceeded, governor.recordFailure, mirrorSuppression
- L323 · loadEnvelope calls (conditional paths may differ): env.BUCKET.get, object.json
- L329 · buildOutbound calls (conditional paths may differ): first, bind, sql.prepare, renderTemplate, JSON.parse, parseAddresses, replace, signTrackingToken, injectUnsubscribe, Boolean, html.matchAll, seen.has, test, seen.add, newId, links.push, pending.push, then, rewritten.set, Promise.all
- L488 · decodeAttachment calls (conditional paths may differ): Array.isArray, Uint8Array.from, atob, binary.charCodeAt
- L499 · recordSent calls (conditional paths may differ): run, bind, sql.prepare, updateOutboundStatus, env.EVENTS_QUEUE.send, Promise.all, recipients.map, eventId
- L566 · mirrorSuppression calls (conditional paths may differ): toISOString, parseAddress, normalizeForSuppression, run, bind, sql.prepare, env.SUPPRESSIONS.put, kvKey.suppression, JSON.stringify
- L593 · markFailed calls (conditional paths may differ): db, tenancyFor, String, console.error, run, bind, sql.prepare, catch, updateOutboundStatus, toISOString, env.EVENTS_QUEUE.send, eventId, message.slice, writeInstanceSetting, console.warn
- L677 · deliverLoopback calls (conditional paths may differ): buildMime, newId, toISOString, filter, split, resolveThreadBySql, r2Key.rawInbound, r2Key.inbound, env.BUCKET.put, JSON.stringify, slice, attachment.filename.replace, attachments.push, Boolean, snippetOf, outbound.to.map, writeMailMessage, encode, normalizeSubject, env.WORKSPACE_HUB.get
Environment references: env.BUCKET · env.SENDING_DOMAIN · env.MS_TRACKING_URL · env.MS_PUBLIC_URL · env.MS_SECRET · env.CACHE · env.EVENTS_QUEUE · env.SUPPRESSIONS · env.WORKSPACE_HUB
- L30 · handleOpen calls (conditional paths may differ): verifyTrackingToken, token.replace, pixelResponse, classifyHit, request.headers.get, env.EVENTS_QUEUE.send, trackingEventId, String, toISOString, slice
- L71 · handleClick calls (conditional paths may differ): verifyTrackingToken, Response.redirect, env.CACHE.get, kvKey.link, classifyHit, request.headers.get, env.EVENTS_QUEUE.send, trackingEventId, String, toISOString, destination.slice, slice
- L123 · handleUnsubscribe calls (conditional paths may differ): verifyTrackingToken, unsubscribe
- L142 · unsubscribe calls (conditional paths may differ): db, tenancyFor, first, bind, sql.prepare, toISOString, JSON.parse, normalizeForSuppression, run, env.SUPPRESSIONS.put, kvKey.suppression, JSON.stringify
Environment references: env.MS_SECRET · env.EVENTS_QUEUE · env.MS_PUBLIC_URL · env.CACHE · env.SUPPRESSIONS
- L40 · asDurableObject calls (conditional paths may differ): impls.set, Object.getOwnPropertyNames, Object.getOwnPropertyDescriptor, Object.defineProperty, impls.get
- L24 · errorResponse calls (conditional paths may differ): err.toBody, Response.json, String, apiError, wrapped.toBody, console.error, toBody
- L63 · enforceRateLimit calls (conditional paths may differ): Math.floor, Date.now, kvKey.rateLimit, Number, ctx.cache.get, apiError, ctx.background, ctx.cache.put, String
- L87 · contextFromRequest calls (conditional paths may differ): getEnv, bearerToken, tenancyFor, actorFromApiKey, tenancy.db, buildContext, actorFromSession, request.headers.has, request.headers.get, apiError
- L118 · withContext calls (conditional paths may differ): contextFromRequest, enforceRateLimit, c.set, next
- L138 · page calls (conditional paths may differ): rows.slice, data.at
Environment references: env.CACHE
- L61 · parseRange calls (conditional paths may differ): q.get, to.getTime, Number.isNaN, from.getTime, apiError, dayKey
- L419 · rollUp calls (conditional paths may differ): row.day.slice, dayKey, at.getTime, at.getUTCDay, keyed.get, keyed.set, num, keyed.values
- L705 · placementFigures calls (conditional paths may differ): seedResults, estimatePlacement
- L710 · seedResults calls (conditional paths may differ): all, bind, ctx.sql.prepare, range.from.toISOString, range.to.toISOString, seeded.results.map
- L773 · estimatePlacement calls (conditional paths may differ): toISOString, first, bind, ctx.sql.prepare, estimateFigure, num, all, range.from.toISOString, range.to.toISOString, providerOf, grouped.get, grouped.set, map, grouped.entries
Environment references: ctx.env.EXPORT_QUEUE
- L76 · newCode calls (conditional paths may differ): crypto.getRandomValues, padStart, String
- L88 · workspaceForEmail calls (conditional paths may differ): first, bind, sql.prepare
- L109 · defaultSendingDomain calls (conditional paths may differ): first, bind, sql.prepare
- L150 · deliverCode calls (conditional paths may differ): getEnv, tenancyFor, tenancy.db, defaultSendingDomain, normalizeEmail, console.log, buildContext, acceptEmail
- L351 · resolveUser calls (conditional paths may differ): toISOString, first, bind, sql.prepare, isClaimed, newId, run
- L470 · accessKeys calls (conditional paths may differ): Date.now, fetch, accessHost, AbortSignal.timeout, apiError, response.json
- L490 · verifyAccessJwt calls (conditional paths may differ): jwt.split, encode, b64urlToBytes, accessKeys, crypto.subtle.importKey, crypto.subtle.verify, JSON.parse, decode, Math.floor, Date.now, Array.isArray, audience.includes, accessHost
Environment references: env.MS_OWNER_EMAIL · env.MS_MODE · env.MS_PUBLIC_URL · env.MS_ACCESS_TEAM · env.MS_ACCESS_AUD
- L579 · load calls (conditional paths may differ): first, bind, ctx.sql.prepare, apiError, JSON.parse
Environment references: ctx.env.AUTOMATION_COHORT · ctx.env.AUTOMATION_QUEUE
Build and deployment pipeline · 2 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: workflow_dispatch, push
deploy · no job dependencies declared
- actions/checkout@v5
actions/checkout@v5 - Require Cloudflare credentials
if [ -z "$CLOUDFLARE_API_TOKEN" ] || [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then echo "::error::Set the CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID repository secrets, then run this workflow again." exit 1 fi - pnpm/action-setup@v4
pnpm/action-setup@v4 - actions/setup-node@v5
actions/setup-node@v5 - Shell command
pnpm install --frozen-lockfile - Typecheck and test
pnpm typecheck pnpm test - Provision the resources the button does not create
pnpm exec tsx scripts/provision.ts - Build
pnpm --filter @mailysend/app build:cf - Apply database migrations
pnpm exec wrangler d1 migrations apply mailysend --remote --config apps/app/wrangler.jsonc - Deploy
pnpm exec wrangler deploy --config apps/app/.output-cf/server/wrangler.json
Triggers: workflow_dispatch
publish · no job dependencies declared
- actions/checkout@v4
actions/checkout@v4 - pnpm/action-setup@v4
pnpm/action-setup@v4 - actions/setup-node@v4
actions/setup-node@v4 - Shell command
pnpm install --frozen-lockfile - Shell command
pnpm typecheck - Shell command
pnpm test - Shell command
pnpm --filter mailysend build - Shell command
pnpm run verify:package - Shell command
pnpm publish --access public --no-git-checks --provenanceCondition: ${{ !inputs.dry_run }}
build: pnpm run build:cfbuild:node: pnpm -r --filter './packages/**' build && pnpm --filter @mailysend/app build:nodebuild:cf: pnpm -r --filter './packages/**' build && pnpm --filter @mailysend/app build:cf && node scripts/emit-root-wrangler.mjs && node scripts/ensure-resources.mjsdeploy:cf: pnpm --filter @mailysend/app run deploy:cf && pnpm --filter @mailysend/track run deploy:cf
build: vite build && tsx ../../scripts/llms.ts .output/clientbuild:node: MS_TARGET=node vite build && tsx ../../scripts/llms.ts .output/clientbuild:cf: MS_TARGET=cloudflare vite build && tsx ../../scripts/llms.ts .output-cf/clientdeploy:cf: pnpm run build:cf && wrangler deploy -c .output-cf/server/wrangler.json
deploy:cf: wrangler deploy
build: esbuild src/main.ts --bundle --format=esm --platform=node --target=node20 --banner:js='#!/usr/bin/env node' --outfile=dist/mailysend.js
build: echo 'contracts: source-only'
build: echo 'core: source-only'
build: echo 'db: source-only'
build: echo 'design-tokens: source-only'
build: echo 'durable: source-only'
build: echo 'events: source-only'
build: echo 'mcp: source-only'
build: echo 'platform: source-only'
build: echo 'providers: source-only'
build: pnpm run build:cli && pnpm run build:js && pnpm run build:typesbuild:cli: pnpm --filter @mailysend/cli run build && node -e "const f=require('node:fs');f.mkdirSync('dist',{recursive:true});f.copyFileSync('../../cli/dist/mailysend.js','dist/cli.js')"build:js: esbuild src/index.ts src/compat.ts --bundle --format=esm --platform=neutral --target=es2022 --outdir=dist --external:@react-email/renderbuild:types: tsc -p tsconfig.build.jsonprepublishOnly: pnpm run build
build: echo 'segments: source-only'
build: echo 'templates: source-only'
build: echo 'ui: source-only'
build: echo 'workflows: source-only'
Repository README
View original on GitHub ↗Full upstream document by @GagnDeep · README.md · snapshot 3708d2f

MailySend
Resend, on your Cloudflare.
The complete email platform — transactional sending, marketing broadcasts, automations, inbound mail with threading and deliverability analytics — running entirely on Cloudflare Workers, in your own account. Drop-in compatible with the Resend API. MIT licensed. Also runs on a plain Node server with no Cloudflare account at all.
Live demo → mailysend.com · Docs · Dashboard tour · Honest comparisons · Cost
Why this exists
You like Resend's API. You do not like that your sending history, your contact list and your bounce data live in someone else's account, on their retention policy, at a per-email markup over what the bytes actually cost.
MailySend is that API — reimplemented on Cloudflare's own primitives — plus everything a platform can do only when it runs where your data already is.
import { Resend } from 'resend'
- const resend = new Resend(process.env.RESEND_API_KEY)
+ const resend = new Resend(process.env.MAILYSEND_API_KEY)
+ // RESEND_BASE_URL=https://your-deployment/v1
The resend npm package honours RESEND_BASE_URL, so that really is the whole migration —
no code changes, no rewrite, no lock-in either direction. There is also a first-party
mailysend SDK, and mailysend/compat exports a Resend class with the same method names
if you would rather be explicit.
What you get
| 📤 Transactional sending | POST /v1/emails, batches of 100, scheduling 30 days out, idempotency keys, attachments, tags, typed errors |
| 🔀 Four transports, one API | Cloudflare Email Service (default), Amazon SES v2, Resend, generic SMTP — deterministic routing, automatic failover, no vendor lock-in |
| 📣 Marketing | Audiences, contacts, custom properties, CSV import, live segments, broadcasts with A/B testing and holdouts, a preference centre |
| 🔁 Automations | Visual step builder on Cloudflare Workflows — send, wait, branch, tag, webhook — in per-contact or cohort mode |
| 📥 Inbound | Real mailboxes, MIME parsing, reply threading, full-text search, attachments streamed straight to R2 |
| 📊 Deliverability | Delivery events, bounce classification, parsed DMARC aggregate reports, seed-list inbox placement with its source labelled |
| 👁️ Analytics | Opens and clicks with bot / MPP classification, per-domain and per-tag breakdowns, daily rollups, long-term NDJSON archive in R2 |
| 🤖 Agents | A nine-tool MCP server so an assistant can read and draft mail — with a confirmation you approve at /app/approvals before anything sends |
| 🎨 Templates | Handlebars, MJML, a restricted JSX AST compiler, versioning with diff and rollback |
| 🔗 Webhooks | HMAC-signed, retried on a queue then a durable tail, every attempt's response stored, replayable |
| 🔐 Auth | Passkeys and single-use recovery codes for the dashboard, Cloudflare Access when you have it, a CLI device flow, hashed API keys for the API, RBAC, invites, audit log — and no password store anywhere |
| 🚀 SEO built in | 42 prerendered pages (marketing, docs and 26 guides), JSON-LD, OG images, sitemap.xml, robots.txt, llms.txt |
How it compares
MailySend vs. the hosted services
This is the same matrix the site publishes at /compare, including the last row.
| MailySend | Resend | Amazon SES | SendGrid | Postmark | |
|---|---|---|---|---|---|
| Model | MIT software, your account | SaaS | Cloud primitive | SaaS | SaaS |
| Cost at 100k/mo | ~$40 to Cloudflare | $90 | ~$10 + your infra | ~$60 | ~$120 |
| Native Worker binding | ✅ | HTTP only | SDK / HTTP | HTTP only | HTTP only |
| Automations / drips | Workflows | Yes | ❌ | Marketing add-on | ❌ |
| Live segmentation | Yes | Yes | ❌ | Yes | ❌ |
| Inbound + threading | Included, free | Limited | Build it | Parse only | Parse only |
| Inbox-placement analytics | Per provider | Delivery only | CloudWatch | Partial | Strong |
| Parsed DMARC aggregate reports | ✅ | ❌ | ❌ | ❌ | ❌ |
| Multi-provider sending + failover | ✅ | ❌ | ❌ | ❌ | ❌ |
| MCP server for agents | ✅ | ❌ | ❌ | ❌ | ❌ |
| Data location | Your account | Theirs | Your AWS | Theirs | Theirs |
| Who is on call | You | Them | AWS | Them | Them |
Resend has grown into a genuinely capable marketing product, and this table says so — the rows the artboards originally marked "No" now read the way a prospect would actually find them. The thing Resend still cannot be is yours. And Postmark measures placement better than we do; that row says so too.
What it costs to send 100,000 emails a month
| Monthly | Basis | |
|---|---|---|
| MailySend + SES | $16.20 | $5 Workers Paid + $0.10/1k + ~$1.20 storage/queues/analytics — one config line |
| MailySend + Cloudflare | $40.15 | $5 Workers Paid + $0.35/1k after the 3,000 included + ~$1.20 — no second account |
| Resend | $90 | published plan ladder (Pro 100k) |
| SendGrid | $60 | ≈$0.60 per 1,000 |
Same product either way: same API, same dashboard, same logs, same analytics, same inbound. The transport is one line of configuration and you can change it later, so the row to read is whichever backend you already have an account with.
Amazon SES on its own is about $10 at this volume, and it is worth being clear about what that buys: an SMTP wire. No dashboard, no event timeline, no segments, no broadcasts, no inbound, and CloudWatch where the analytics would be. MailySend runs on top of SES for the same $0.10 per thousand — that is the first row of this table, not a competitor to it.
Estimates for planning, not a quote — and the same formulas the site's own calculator runs, so you can move the slider at mailysend.com/pricing and check them. The dashboard also shows your real month-to-date Cloudflare spend next to your send volume, so the estimate is answerable to a number.
See it running
Everything below is the live deployment at mailysend.com — one Node process behind nginx, or one Worker, from this exact repository. Real traffic, not empty states.
The overview, on a month of sending
Sent, delivered, bounced, complained and the hourly curve — with the counts of record coming from SQL rollups, never from a sampled analytics store.

Analytics that names its own denominators
A 30-day series, delivery grouped by receiving domain and by the tags you set at send time, opens and clicks split by who actually generated them, and inbox placement per provider with the source of every figure attached.

Every message, and what happened to it The full log with filters that live in the URL, and a drawer per message: state timeline, the SMTP conversation, every webhook attempt and its response, and the raw MIME. ![]() |
One message, all the way down ![]() |
Domain setup that finishes Every DNS record for the active transport, copy-buttoned, with SPF/DKIM/DMARC state and the deliverability posture on one page. ![]() |
Broadcasts with a real denominator Progress from the coordinator's own counters, and every engagement rate stated over the denominator it was actually computed from. ![]() |
Audiences and live segments Contacts, custom properties, CSV import, and segments written in a real query DSL that compiles to parameterised SQL. ![]() |
⌘K to anywhere Jump to any message, domain, template or doc page. ![]() |
Get running
Path 1 — Cloudflare Workers (one click)
The button forks the repo, connects it to Workers Builds, then builds and deploys. The build creates the account resources the Worker binds — see below; the button itself provisions less than its documentation implies.
The deploy form has no fields on it. There is nothing you need to know before the first boot: on its first request the instance applies its own migrations, creates the workspace, generates and stores a 32-byte signing secret, learns its own public URL from the request it is answering, and prints one bootstrap API key to the log.
That form is built from the repo's .env.example, and it is worth knowing exactly what
Cloudflare does with it: it shows key names only — never the comments — it stores every
answer as a secret, and it does not prefill from the values in the file. So a key with
a perfectly good default renders as a blank, masked, mandatory-looking password box,
indistinguishable from a credential the deployment cannot start without. That is why the
file carries no keys at all. Every variable — MS_MODE, MS_LANDING,
MS_DEFAULT_PROVIDER, EVENT_DETAIL, MS_OWNER_EMAIL and the MS_OIDC_* group — is
listed with its default in docs/CONFIGURATION.md and set
afterwards with wrangler secret put or a vars entry.
When it finishes, open the deployment's URL. It lands on /setup, where you claim the
instance with a passkey — no email, no DNS and no identity provider needed, because a
freshly deployed Worker has none of those.
The first person to reach /setup takes the deployment, so claim it now rather than
later. Setting MS_OWNER_EMAIL narrows the claim to one address — it does not create an
owner, it restricts who may become one. For a URL that is public before you get to it,
MS_REQUIRE_CLAIM_CODE=1 makes first boot print a claim code to the deploy log
(wrangler tail, or the Worker's Logs tab) that /setup then demands, so whoever finds
the deployment first cannot claim it without reading its log. Everything else can wait
until you are inside.
The build creates what the button does not. wrangler deploy validates every binding
before it uploads, so a missing queue or namespace is a failed deploy rather than a
degraded Worker — a clean account used to fail one resource at a time. build:cf therefore
ends by running scripts/ensure-resources.mjs, which creates the twelve queues, the R2
bucket, the D1 database and the two KV namespaces, then writes the generated D1 and KV ids
into the config wrangler deploys. It matches by name and creates only what is missing, so
every build after the first is a no-op — which matters, because re-creating SUPPRESSIONS
rather than reusing it would silently empty it. It acts only inside Workers Builds
(WORKERS_CI=1) or under MS_ENSURE_RESOURCES=1, so building locally never touches your
account. Analytics Engine datasets need nothing; they are created on first write.
To do it yourself instead, from your own machine:
export CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=...
npx mailysend provision
That token needs Queues:Edit, plus Workers Scripts:Edit, D1:Edit, Workers KV:Edit
and Workers R2:Edit if you also deploy with it rather than from the button.
.github/workflows/deploy.yml runs both steps on workflow_dispatch if you would rather
it happened in CI.
The commands Cloudflare guesses now work. They did not always: it proposes pnpm deploy,
which never runs the script of that name because deploy is one of pnpm's own subcommands
and the built-in always wins, and then a bare npx wrangler deploy from the repo root,
where wrangler cannot tell which workspace package is the Worker. So no script here is
called deploy any more, and build:cf ends by writing a root wrangler.json — a copy of
the config Vite generates next to the bundle, with its paths rewritten, regenerated on
every build and gitignored.
To set them explicitly instead, under Workers → your Worker → Settings → Builds:
| Build command | pnpm run build:cf |
| Deploy command | npx wrangler deploy -c apps/app/.output-cf/server/wrangler.json |
The -c matters: the Worker is built by Vite, and the wrangler config it deploys from is
the one Vite generates next to the bundle, not the apps/app/wrangler.jsonc you edit.
Path 2 — a Node server (no Cloudflare account)
node:sqlite backs the database, the filesystem backs blobs, and in-process actors back
the Durable Objects. Same code, different driver.
pnpm install
pnpm build:node
PORT=8917 node apps/app/node-server.mjs
That is the whole command — there is no required environment variable on this path
either. First boot migrates the database, creates the workspace, generates and stores the
signing secret, and prints one API key. The key is printed exactly once, because only its
SHA-256 hash is ever stored. Then open http://localhost:8917/, which sends you to
/setup to claim the instance with a passkey.
The first person to reach /setup claims it. Set MS_OWNER_EMAIL=you@your-domain.com to
narrow the claim to a single address, MS_REQUIRE_CLAIM_CODE=1 to have first boot print a
claim code that /setup then asks for — the log is the one place it exists, and
reading the log is the proof — and MS_SECRET to a 32-byte hex string if you would rather keep the signing key out of the
database and be able to rotate it. All optional. The full list is in
docs/CONFIGURATION.md.
The guides, once it is up:
| docs/SENDING.md | Choosing a transport, and a real walkthrough for each of the four |
| docs/RECEIVING.md | Email Routing, the catch-all toggle, mailboxes, the MX preflight, and what matched_by means |
| docs/MAIL.md | The inbox, test mode, and the keyboard |
| docs/AUTH.md | Every door in, OIDC included |
| docs/MCP.md | The nine agent tools, the confirmation protocol, and how a key scopes an agent |
| docs/AGENTS.md | The agent skill, and what to give an agent first |
| docs/WEBHOOKS.md | Signature, tolerance, the retry ladder, auto-disable |
| docs/CONFIGURATION.md | Environment variables and bindings |
Production: PM2 + nginx (this is how the live demo runs)
pnpm build:node
pm2 start ecosystem.config.cjs
pm2 save && pm2 startup
ecosystem.config.cjs runs one fork-mode process on purpose. On Node the Durable
Objects are in-process actors, and an actor's entire job is to be a single serialisation
point per key — two processes would each hold their own broadcast cursors and their own
copy of the daily-quota governor, and the governor would then grant twice what it should.
map $http_x_forwarded_proto $ms_forwarded_proto {
"" $scheme;
default $http_x_forwarded_proto;
}
server {
listen 80;
server_name your-domain.com;
client_max_body_size 200M;
location / {
proxy_pass http://localhost:8917;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $ms_forwarded_proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
}
X-Forwarded-Proto is load-bearing: without it every absolute URL the app mints —
tracking pixels, unsubscribe links, canonical tags — comes out http:// on an https
site.
Then send something
curl https://your-deployment/v1/emails \
-H "Authorization: Bearer ms_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "you@your-domain.com",
"to": ["someone@example.com"],
"subject": "Hello",
"html": "<p>It works.</p>"
}'
The id comes back before any provider is contacted. That is deliberate: the id is
ours, minted at accept time, so it survives a failover, a provider migration, and a
provider that loses its own id. provider_message_id is recorded later and is queryable,
but it is never the identity of a message.
And then receive something
Sending and receiving are two independent setups on the same domain, and the second one is configured in two different places — which is the whole reason the first test message usually bounces.
- Cloudflare dashboard → Email → Email Routing. Enable it (Cloudflare publishes the MX records itself), then add a catch-all rule whose action is Send to a Worker, pointed at this instance's script. That is what delivers the domain's mail to MailySend.
- In MailySend, under the domain's Receiving tab, create a mailbox — and turn on Catch-all on it if you want every address on the domain to land there rather than only the one you named.
Step 1 alone is not enough. Mail for an address with no mailbox and no catch-all is refused
at the door with a legible 550 5.1.1 No such mailbox, which is the honest answer to a
typo and tells a spammer nothing — but it is also exactly what "I bound the catch-all and
nothing arrived" looks like. Check receiving on the domain resolves its MX and says
which of the two halves is missing, and both outcomes are written to the event timeline
rather than only to wrangler tail.
Getting into the dashboard
The API takes a key; the dashboard takes a session. A brand-new deployment has no verified sending domain, no identity provider and nobody to email, so the first session cannot come from any of those. It comes from claiming the instance.
1 — Claim it at /setup. The first person to open it registers a passkey and becomes
the owner. The claim is a single conditional insert, so two people opening /setup at the
same moment produce exactly one owner — the other is told the instance is already claimed.
Then it offers, both skippable, adding a sending domain and minting your first API key
with a live test send.
2 — Save the recovery codes. Ten of them, shown once, single-use. They are the way back in if the passkey is gone, and the reason removing your last passkey is allowed at all.
3 — Sign in afterwards with the passkey alone. No email typed: the credential is
discoverable, so the browser offers it and /sign-in trades the assertion for a session.
Three other doors exist, and the sign-in page renders each one only when it is actually open — an offered door that answers 501 is worse than no door:
| Door | When it appears | What it needs |
|---|---|---|
| Recovery code | always | one of the ten codes |
| Cloudflare Access | MS_ACCESS_TEAM and MS_ACCESS_AUD are set |
the assertion is verified against your team's published keys — signature, iss, aud, exp — never merely decoded |
| Emailed one-time code | a sending domain is verified | it is sent through this deployment's own send path, from the domain you marked default |
Until a domain is verified, POST /v1/auth/otp answers 202 {"status":"unavailable"} and
the page says so, instead of pointing you at an inbox that will never receive anything.
Locked out? npx mailysend claim --url https://your-instance is the break-glass path,
and it keeps working after the instance is claimed. It proves control of the deployment
rather than of an inbox: it writes a one-time nonce into the instance's own database —
through the Cloudflare D1 API when CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are
in your environment, otherwise by printing the exact wrangler d1 execute command for you
to run — and then proves it knows that nonce. Only somebody who can write that database
can produce it. You get a session and a fresh API key.
On the CLI, npx mailysend login uses a device code: it prints an eight-character code
and a URL, you approve it at Settings → Access in a browser that is already signed
in, and the CLI receives a full-access key named after the client — revocable from the same
screen as any other key.
Moving to your own domain is done from Settings → Access. This matters more
than it looks: a passkey is bound to a hostname, so changing the host invalidates every
passkey registered on the old one. The instance stores the hostname each credential was
registered for, shows you which are still usable, and tells you plainly that you need a
recovery code and a re-registration on the new host. mailysend claim remains the
guaranteed way in, which is what makes offering the move as a button safe at all.
There is no password store. A self-hosted email platform that invents one is adding the single credential most likely to be reused and leaked.
Upgrading a deployment that predates the claim flow
Pull and redeploy; the new migration runs itself on the first request. Nothing else is required, and nothing you already have is invalidated — existing API keys, sessions, domains and messages are untouched.
What changes:
- An instance with an owner already stays claimed. The first boot after the upgrade
marks any deployment that already has a member as claimed, so
/setupwill not offer your instance to a passer-by. If yours somehow has no member — the common case for a deploy nobody ever managed to sign into — it is unclaimed, and/setupis how you finally get in. /now redirects to/app(or/setup) on a self-hosted instance. If your deployment is meant to serve the public marketing site at its root, setMS_LANDING=marketing./sign-upis gone, replaced by/setup. The old path redirects.- Add a passkey from Settings once you are in, and generate recovery codes. Until you do, your only doors are the ones you already had.
- Pin your hostname if you serve the instance on more than one —
MS_PUBLIC_URL, or Settings → Access — before registering passkeys, since a later host change invalidates them.
What we are honest about
A deliverability product that overstates what it measures loses credibility permanently. These appear in the docs, in the UI, and here.
- Delivery semantics. Exactly-once for API acceptance and for state and event accounting. At-least-once for wire delivery, webhook delivery and analytics datapoints. Anyone claiming exactly-once SMTP delivery is lying.
- One duplicate source is not fully defensible: the provider accepted the message and
the worker died before the database write. No provider offers an idempotency key for
this. It is minimised (
provider_message_idwritten first, a 120-second lease) and it is measured and alerted on rather than claimed away. - Inbox placement is not observable from delivery events. SMTP
250means accepted, not inboxed. Every placement figure carriessource(seed/postmaster/snds/estimate) and a confidence, and estimates are visibly labelled as estimates. - Open rates are approximate. Apple Mail Privacy Protection prefetches every pixel.
Nothing is discarded — each hit is classified
human/mpp/proxy_prefetch/scanner/bot— charts default tohuman, and the privacy-adjusted rate excludes MPP from both sides of the ratio. - Cloudflare Email Service is in beta, Workers Paid only, with a daily quota that ramps with reputation and is not published. MailySend learns that ceiling (halve on rejection, raise at most 2× after a clean day), so a first large send slows itself down instead of generating thousands of errors and a reputation hit.
- Attachment limits are per transport. Cloudflare caps a message at 5 MiB (25 MiB only to verified destinations); SES at 40 MB. The API returns a typed error naming the active provider's limit rather than failing at the wire.
- SMTP ingress cannot run on Workers.
connect()is egress-only and there is no inbound TCP listener.apps/smtp-shimis a container image. Self-hosters who will not run one can point at Cloudflare's ownsmtp.mx.cloudflare.net:465— but those sends will not appear in MailySend's logs, analytics or webhooks, and the docs say so. - Automations have a real ceiling. Workflows V2 caps 50,000 concurrent instances. Instance mode (exact per-contact timing) is capped at 40,000 active enrollments and refuses beyond it. Cohort mode is the default at audience scale — one instance per hourly cohort of ≤25,000, which puts 500,000 contacts over a month at roughly 720 instances, at the cost of timing quantised to the cohort clock.
- Analytics Engine keeps three months and samples under load. It is the hot query layer
for charts. The count of record is
rollups_dailyin SQL; the archive is NDJSON in R2. alertsandalert_incidentsare schema, not a feature. The tables exist and nothing reads or writes them; there is no alerting in the product. They are named here rather than left to be discovered in a schema dump.- One SDK is first-party.
mailysendfor Node is written and published. The other languages are generated from/v1/openapi.jsonwithopenapi-generator— we ship the spec rather than claim nine hand-maintained SDKs.
Architecture
apps/
app/ TanStack Start SSR + Hono /v1 + /mcp + every Durable Object
+ the email() handler + every queue consumer + cron
track/ the tracking Worker — /o/*, /c/*, /u/* only, no database binding
smtp-shim/ SMTP ingress as a container (Workers cannot listen on a TCP port)
packages/
design-tokens/ colour, type and shape — CSS custom properties + Tailwind theme + TS
ui/ shadcn primitives rethemed, plus this design's own vocabulary
contracts/ Zod schemas → validation, OpenAPI, SDKs and dashboard types
core/ ids, the tenancy seams, every durable key, crypto
db/ Drizzle schema and one migration set for D1 *and* node:sqlite
platform/ the runtime seam: Sql, Kv, Blob, Queue, Actor, Analytics
providers/ cloudflare | ses | resend | smtp adapters, routing and failover
events/ one normalized schema, deterministic ids, the state ladder
durable/ every actor class
segments/ the DSL parser and its parameterised SQL compiler
templates/ Handlebars, MJML, a restricted JSX AST, and HTML post-processing
workflows/ the automation step interpreter, on Workflows or the scheduler
mcp/ the nine-tool MCP server
sdk-node/ the `mailysend` npm package + `mailysend/compat`
cli/ npx mailysend
The seven decisions worth knowing
One codebase, two runtimes. packages/platform defines Sql, Kv, Blob, Queue,
ActorNamespace and Analytics as deliberate subsets of the Cloudflare APIs. The
Cloudflare adapters are therefore identity casts — zero cost — and the abstraction cannot
drift, because drifting would mean diverging from the API it is a subset of. D1 and
node:sqlite are the same engine, so one migration set covers both.
Prefixed ULIDs. em_, dom_, bc_ — time-sortable, so an id doubles as an index range
key and an R2 partition prefix, and pagination is WHERE id < ? rather than an offset.
A monotonic state ladder. Every status write is WHERE state_rank < ?. Out-of-order and
duplicate events become no-ops, so the event pipeline needs no ordering guarantees at all.
Deterministic event identity.
event_id = sha256(provider|provider_message_id|type|recipient|unix_second). A redelivered
webhook produces a byte-identical id and collapses on an INSERT OR IGNORE. The timestamp
is truncated to the second because providers re-serialise sub-second precision between
retries.
Deterministic provider routing. The transport is chosen by stableHash(email_id), so a
retry always lands on the same provider and cannot double-send across two. Failover happens
on transient and throttled errors and never on unknown — a timeout with an unknown
outcome means the message may already be on the wire.
Broadcasts are O(1) at the coordinator. Preparation splits the recipient set into 32 contiguous id ranges; the coordinator stores 32 cursors and nothing else. Its write rate is ~6/second whether the audience is a thousand contacts or half a million.
Segments recompute without scanning. Behavioural fields are denormalised columns on
contacts, a write-driven delta covers edits, and an hourly boundary sweep covers the
genuinely hard case — last_open < 30d flips with no write at all — by querying only the
hour that just expired.
Configuration
Nothing here is required. The table is what you may want to override, not a checklist to work through before the first boot — see docs/CONFIGURATION.md for the long form.
| Variable | Default | What it does |
|---|---|---|
MS_SECRET |
generated on first boot, stored in settings |
Signs tracking, unsubscribe and reply tokens. Set it to keep the key out of the database and to be able to rotate it; must be ≥32 characters |
MS_PUBLIC_URL |
learned from the first non-local request | Base URL for every link the app mints. Set it to pin the value, e.g. behind a proxy that rewrites the Host |
MS_TRACKING_URL |
MS_PUBLIC_URL |
Separate tracking domain, if you have one |
MS_MODE |
single |
single (self-hosted) or saas |
MS_DATA_KEY |
MS_SECRET |
Encrypts stored provider credentials |
MS_DEFAULT_PROVIDER |
cloudflare |
Fallback transport when nothing is configured |
MS_OWNER_EMAIL |
— | Optional and restrictive: it does not create an owner, it limits who may claim the instance at /setup. Not on the Cloudflare deploy form — set it with wrangler secret put. When set, the claim code is not asked for |
MS_REQUIRE_CLAIM_CODE |
off | 1, true, yes, on or required mints a claim code on first boot, prints it to the log and makes /setup ask for it. For a URL that is public before you reach /setup |
MS_LANDING |
app in single mode |
What / serves. app redirects to your dashboard (or /setup while unclaimed); marketing serves the public site, which is what mailysend.com runs |
MS_ACCESS_TEAM |
— | Cloudflare Access team domain, e.g. acme.cloudflareaccess.com |
MS_ACCESS_AUD |
— | The Access application's AUD tag. Both are required for Access sign-in |
EVENT_DETAIL |
on |
off stops writing per-event rows and reconstructs timelines from R2 |
PORT / MS_DATA_DIR |
8917 / ./.data |
Node deployments only |
Provider credentials set in the dashboard are stored as AES-GCM ciphertext and are never returned by the API. Environment variables are the fallback, which is what lets a fresh deployment send on its very first request.
Development
pnpm install
pnpm dev # vite dev on :8917
pnpm typecheck
pnpm test # 764 tests
pnpm lint
pnpm --filter @mailysend/app preview # wrangler dev, on Miniflare
The dev cache (.vite-dev) and the build outputs (.output for Node, .output-cf for
Cloudflare) are separate directories, so a running dev server and a production build never
contend for the same files.
The check that matters most is scripts/contract-check.mts. It boots the server, seeds a
row of every kind, then reads all 23 dashboard endpoints back through the dashboard's own
Zod schemas — so schema drift surfaces as a failed check rather than as an error card in
production. Its first version passed while the domains screen was broken, because there was
no domain to disagree about; seeding first is the fix.
node apps/app/node-server.mjs &
KEY=ms_live_... pnpm exec tsx scripts/contract-check.mts
Licence
MIT — see LICENSE. Do what you like with it, including running it as your own hosted service.
mailysend.com — the live deployment, running this repository.
Frequently asked about MailySend
What is MailySend?+
MailySend is a self-hosted Postmark/Resend alternative built on the Cloudflare developer platform. Self-hosted transactional email APIs, queues and delivery management
What does MailySend replace?+
MailySend is listed as an alternative to Postmark, Resend. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does MailySend use?+
MailySend is built on Analytics Engine, D1, Durable Objects, KV, Queues, R2, Workers.
How much does MailySend cost to run?+
The Cloudflare Email Service path requires Workers Paid from $5 USD/account/month, includes 3,000 sends/month, then bills $0.35/1,000. Storage, queues and DO overages are additional. Analytics Engine is currently not billed, per its primary pricing page. SES, Resend and SMTP are alternative transports with separate provider fees; SMTP ingress requires an optional external shim. Configure an owned sending domain and actual transport entitlement; API acceptance does not guarantee inbox delivery. The optional SMTP-ingress shim is outside the Workers HTTP API core and has its own hosting cost. Budget quotas for both send and tracking Workers. Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested. Check current Cloudflare pricing before deploying.
Is MailySend open source?+
The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/GagnDeep/mailysend/3708d2f6fdfbb95aa979284733ae83950dc6e115/LICENSE. Source code and contributor credit are available at https://github.com/GagnDeep/mailysend.








Discussion · 0
sign in to comment →