Cloudsteading
MailySend dashboard overview showing 1,839 sent, 99.3% delivered and an hourly send curve

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

@GagnDeep

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.
Check current pricing ↗
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.

Resend logoResend ↗

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 ↗
Postmark logoPostmark ↗

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 ↗
external SaaS target
varies
external SaaS target
varies

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 ↗
Public interface
Configured entry points2
mailysend
apps/app/wrangler.jsonc
mailysend-track
apps/track/wrangler.jsonc
↓
App
mailysend
entry
Cloudflare Workers
Entrypoint: src/server.tsConfigured cron (UTC): * * * * *
mailysend-track
entry
Cloudflare Workers
Entrypoint: src/index.ts
↓
Bindings
DB
D1 · mailysend
CACHE
KV · mailysend
SUPPRESSIONS
KV · mailysend
BUCKET
R2 · mailysend
SENDING_DOMAIN
Durable Objects · mailysend · SendingDomainDO
BROADCAST
Durable Objects · mailysend · BroadcastDO
BROADCAST_COUNTER
Durable Objects · mailysend · BroadcastCounterDO
WEBHOOK_ENDPOINT
Durable Objects · mailysend · WebhookEndpointDO
SCHEDULE_SHARD
Durable Objects · mailysend · ScheduleShardDO
MAILBOX
Durable Objects · mailysend · MailboxDO
SEGMENT
Durable Objects · mailysend · SegmentDO
AUTOMATION_COHORT
Durable Objects · mailysend · AutomationCohortDO
AUTOMATION_RUN
Durable Objects · mailysend · AutomationRunDO
WORKSPACE_HUB
Durable Objects · mailysend · WorkspaceHubDO
CF
EMAIL_EVENTS
Analytics Engine · mailysend
CF
API_USAGE
Analytics Engine · mailysend
SEND_QUEUE
Queues producer · mailysend
SEND_BULK_QUEUE
Queues producer · mailysend
EVENTS_QUEUE
Queues producer · mailysend
WEBHOOKS_QUEUE
Queues producer · mailysend
BROADCAST_QUEUE
Queues producer · mailysend
INBOUND_QUEUE
Queues producer · mailysend
SEGMENTS_QUEUE
Queues producer · mailysend
AUTOMATION_QUEUE
Queues producer · mailysend
DMARC_QUEUE
Queues producer · mailysend
EXPORT_QUEUE
Queues producer · mailysend
ms-send
Queues consumer · mailysend
ms-send-bulk
Queues consumer · mailysend
ms-events-cf
Queues consumer · mailysend
ms-events-norm
Queues consumer · mailysend
ms-webhooks
Queues consumer · mailysend
ms-broadcast-pages
Queues consumer · mailysend
ms-inbound
Queues consumer · mailysend
ms-segments
Queues consumer · mailysend
ms-automation-triggers
Queues consumer · mailysend
ms-dmarc
Queues consumer · mailysend
ms-export
Queues consumer · mailysend
CF
SEND_EMAIL
Send Email · mailysend
ASSETS
Static assets · mailysend
CACHE
KV · mailysend-track
EVENTS_QUEUE
Queues producer · mailysend-track

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
apps/app/wrangler.jsonc ↗

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 → D1
  • CACHE → KV
  • SUPPRESSIONS → KV
  • BUCKET → R2
  • SENDING_DOMAIN → Durable Objects · class SendingDomainDO
  • BROADCAST → Durable Objects · class BroadcastDO
  • BROADCAST_COUNTER → Durable Objects · class BroadcastCounterDO
  • WEBHOOK_ENDPOINT → Durable Objects · class WebhookEndpointDO
  • SCHEDULE_SHARD → Durable Objects · class ScheduleShardDO
  • MAILBOX → Durable Objects · class MailboxDO
  • SEGMENT → Durable Objects · class SegmentDO
  • AUTOMATION_COHORT → Durable Objects · class AutomationCohortDO
  • AUTOMATION_RUN → Durable Objects · class AutomationRunDO
  • WORKSPACE_HUB → Durable Objects · class WorkspaceHubDO
  • EMAIL_EVENTS → Analytics Engine
  • API_USAGE → Analytics Engine
  • SEND_QUEUE → Queues (producer) · queue ms-send
  • SEND_BULK_QUEUE → Queues (producer) · queue ms-send-bulk
  • EVENTS_QUEUE → Queues (producer) · queue ms-events-norm
  • WEBHOOKS_QUEUE → Queues (producer) · queue ms-webhooks
  • BROADCAST_QUEUE → Queues (producer) · queue ms-broadcast-pages
  • INBOUND_QUEUE → Queues (producer) · queue ms-inbound
  • SEGMENTS_QUEUE → Queues (producer) · queue ms-segments
  • AUTOMATION_QUEUE → Queues (producer) · queue ms-automation-triggers
  • DMARC_QUEUE → Queues (producer) · queue ms-dmarc
  • EXPORT_QUEUE → Queues (producer) · queue ms-export
  • ms-send → Queues (consumer) · queue ms-send · dead letters ms-dlq
  • ms-send-bulk → Queues (consumer) · queue ms-send-bulk · dead letters ms-dlq
  • ms-events-cf → Queues (consumer) · queue ms-events-cf · dead letters ms-dlq
  • ms-events-norm → Queues (consumer) · queue ms-events-norm · dead letters ms-dlq
  • ms-webhooks → Queues (consumer) · queue ms-webhooks · dead letters ms-dlq
  • ms-broadcast-pages → Queues (consumer) · queue ms-broadcast-pages · dead letters ms-dlq
  • ms-inbound → Queues (consumer) · queue ms-inbound · dead letters ms-dlq
  • ms-segments → Queues (consumer) · queue ms-segments · dead letters ms-dlq
  • ms-automation-triggers → Queues (consumer) · queue ms-automation-triggers · dead letters ms-dlq
  • ms-dmarc → Queues (consumer) · queue ms-dmarc · dead letters ms-dlq
  • ms-export → Queues (consumer) · queue ms-export · dead letters ms-dlq
  • SEND_EMAIL → Send Email
  • ASSETS → Static assets
apps/track/wrangler.jsonc ↗

Cloudflare Workers · compatibility 2026-09-01

mailysend-track · default

Entrypoint: src/index.ts

  • CACHE → KV
  • EVENTS_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.

apps/app/src/server.ts ↗
  • 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

apps/track/src/index.ts ↗
  • 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

apps/app/src/server/api/demo.ts ↗

    Environment references: env.CACHE

    apps/app/src/server/api/index.ts ↗

      Environment references: ctx.env.MS_MODE · env.DB · env.MS_MODE

      apps/app/src/server/auth.ts ↗
      • 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
      apps/app/src/server/bootstrap.ts ↗
      • 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

      apps/app/src/server/consumers/broadcast.ts ↗
      • 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

      apps/app/src/server/consumers/events.ts ↗
      • 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

      apps/app/src/server/consumers/inbound.ts ↗
      • 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

      apps/app/src/server/consumers/webhooks.ts ↗
      • 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

      apps/app/src/server/context.ts ↗
      • 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

      apps/app/src/server/cron.ts ↗
      • 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

      apps/app/src/server/inbound-handler.ts ↗
      • 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

      apps/app/src/server/send/consumer.ts ↗
      • 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

      apps/app/src/server/tracking.ts ↗
      • 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

      apps/app/src/server/durable-objects.ts ↗
      • L40 · asDurableObject calls (conditional paths may differ): impls.set, Object.getOwnPropertyNames, Object.getOwnPropertyDescriptor, Object.defineProperty, impls.get
      apps/app/src/server/api/base.ts ↗
      • 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

      apps/app/src/server/api/analytics.ts ↗
      • 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

      apps/app/src/server/api/auth.ts ↗
      • 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

      apps/app/src/server/api/automations.ts ↗
      • 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.

      Deploy · .github/workflows/deploy.yml ↗

      Triggers: workflow_dispatch, push

      deploy · no job dependencies declared

      1. actions/checkout@v5actions/checkout@v5
      2. Require Cloudflare credentialsif [ -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
      3. pnpm/action-setup@v4pnpm/action-setup@v4
      4. actions/setup-node@v5actions/setup-node@v5
      5. Shell commandpnpm install --frozen-lockfile
      6. Typecheck and testpnpm typecheck pnpm test
      7. Provision the resources the button does not createpnpm exec tsx scripts/provision.ts
      8. Buildpnpm --filter @mailysend/app build:cf
      9. Apply database migrationspnpm exec wrangler d1 migrations apply mailysend --remote --config apps/app/wrangler.jsonc
      10. Deploypnpm exec wrangler deploy --config apps/app/.output-cf/server/wrangler.json
      Publish SDK · .github/workflows/publish-sdk.yml ↗

      Triggers: workflow_dispatch

      publish · no job dependencies declared

      1. actions/checkout@v4actions/checkout@v4
      2. pnpm/action-setup@v4pnpm/action-setup@v4
      3. actions/setup-node@v4actions/setup-node@v4
      4. Shell commandpnpm install --frozen-lockfile
      5. Shell commandpnpm typecheck
      6. Shell commandpnpm test
      7. Shell commandpnpm --filter mailysend build
      8. Shell commandpnpm run verify:package
      9. Shell commandpnpm publish --access public --no-git-checks --provenanceCondition: ${{ !inputs.dry_run }}
      package.json ↗
      • build: pnpm run build:cf
      • build:node: pnpm -r --filter './packages/**' build && pnpm --filter @mailysend/app build:node
      • build:cf: pnpm -r --filter './packages/**' build && pnpm --filter @mailysend/app build:cf && node scripts/emit-root-wrangler.mjs && node scripts/ensure-resources.mjs
      • deploy:cf: pnpm --filter @mailysend/app run deploy:cf && pnpm --filter @mailysend/track run deploy:cf
      apps/app/package.json ↗
      • build: vite build && tsx ../../scripts/llms.ts .output/client
      • build:node: MS_TARGET=node vite build && tsx ../../scripts/llms.ts .output/client
      • build:cf: MS_TARGET=cloudflare vite build && tsx ../../scripts/llms.ts .output-cf/client
      • deploy:cf: pnpm run build:cf && wrangler deploy -c .output-cf/server/wrangler.json
      apps/track/package.json ↗
      • deploy:cf: wrangler deploy
      cli/package.json ↗
      • build: esbuild src/main.ts --bundle --format=esm --platform=node --target=node20 --banner:js='#!/usr/bin/env node' --outfile=dist/mailysend.js
      packages/contracts/package.json ↗
      • build: echo 'contracts: source-only'
      packages/core/package.json ↗
      • build: echo 'core: source-only'
      packages/db/package.json ↗
      • build: echo 'db: source-only'
      packages/design-tokens/package.json ↗
      • build: echo 'design-tokens: source-only'
      packages/durable/package.json ↗
      • build: echo 'durable: source-only'
      packages/events/package.json ↗
      • build: echo 'events: source-only'
      packages/mcp/package.json ↗
      • build: echo 'mcp: source-only'
      packages/platform/package.json ↗
      • build: echo 'platform: source-only'
      packages/providers/package.json ↗
      • build: echo 'providers: source-only'
      packages/sdk-node/package.json ↗
      • build: pnpm run build:cli && pnpm run build:js && pnpm run build:types
      • build: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/render
      • build:types: tsc -p tsconfig.build.json
      • prepublishOnly: pnpm run build
      packages/segments/package.json ↗
      • build: echo 'segments: source-only'
      packages/templates/package.json ↗
      • build: echo 'templates: source-only'
      packages/ui/package.json ↗
      • build: echo 'ui: source-only'
      packages/workflows/package.json ↗
      • build: echo 'workflows: source-only'

      Full upstream document by @GagnDeep · README.md · snapshot 3708d2f

      MailySend — Resend, on your Cloudflare

      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

      License: MIT Runs on Cloudflare Workers Also runs on Node 24

      Deploy to Cloudflare


      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.

      MailySend dashboard overview showing 1,839 sent, 99.3% delivered and an hourly send curve

      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.

      Analytics page: totals, a 30-day timeseries, breakdowns by domain and tag

      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.

      Message log with delivered, bounced, queued and sending rows

      One message, all the way down

      Log drawer showing the state timeline, SMTP transcript and webhook attempts

      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.

      Domain detail with DNS records and deliverability

      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.

      Broadcast detail with progress and engagement rates

      Audiences and live segments

      Contacts, custom properties, CSV import, and segments written in a real query DSL that compiles to parameterised SQL.

      Contacts and audiences

      ⌘K to anywhere

      Jump to any message, domain, template or doc page. G L for logs, G B for broadcasts. No screen in the product ends in "contact support".

      Command palette searching messages by subject
      Honest comparisons — including where we lose
      The comparison page ships the rows where we lose, too. See it live →

      Get running

      Path 1 — Cloudflare Workers (one click)

      Deploy to Cloudflare

      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.

      1. 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.
      2. 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 /setup will 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 /setup is 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, set MS_LANDING=marketing.
      • /sign-up is 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_id written 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 250 means accepted, not inboxed. Every placement figure carries source (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 to human, 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-shim is a container image. Self-hosters who will not run one can point at Cloudflare's own smtp.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_daily in SQL; the archive is NDJSON in R2.
      • alerts and alert_incidents are 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. mailysend for Node is written and published. The other languages are generated from /v1/openapi.json with openapi-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 →
      No comments yet — be the first.