big-mailer
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@big-mailershow me subscribers who unsubscribed from the onboarding sequence"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
big-mailer ๐ฌ
Broadcasts, drip sequences, and transactional email in one Cloudflare Worker. A self-hosted replacement for a paid ESP, where the list, the sending, and the engagement data stay yours.
Status: feature-complete and runnable locally, not yet deployed. It is built for a single operator and is not multi-tenant, by design rather than by omission. See Before deploying for the honest list of what's left.

The dashboard after seeding demo data. Consent, by scope is the panel that matters: two people left an individual series and are still on the list. On a normal ESP those two numbers are the same number.
The idea ๐ก
Every ESP treats unsubscribe as one switch. Someone finishes your onboarding series, clicks "unsubscribe" to stop that, and quietly leaves your newsletter forever. You never find out. The number just goes down.
Here, consent is scoped. Leaving one sequence takes you off that sequence. Leaving the newsletter doesn't cancel a series you deliberately opted into. Only an explicit "unsubscribe from everything", a hard bounce, or a spam complaint removes someone outright.
That asymmetry is the reason this exists, and everything else in the codebase is arranged so it can't be broken by accident.
Related MCP server: Resend MCP Server
Run it ๐
bun install
bun run db:migrate # applies migrations to the local D1 database
bun run dev # http://localhost:8787Open http://localhost:8787 and click Seed demo data: twelve people, two live series, a sent broadcast. Then open the Outbox to read the mail that "went out."
Nothing leaves your machine. EMAIL_PROVIDER=console is the local default and writes
fully rendered mail to the in-app Outbox instead of sending it. No API key needed, and
no way to accidentally mail a real person while you poke at it.
Try the thing it's for ๐ฏ
Subscribers โ pick someone โ Open their preference center
Add
?scope=sequence:2to that URL. This is what a link inside a sequence email looks like.Hit Stop just this series
Go back to their subscriber page: still
active, still on the newsletter, out of exactly one seriesConsent shows everyone who left a single series versus the (empty) list of people gone entirely
Send a broadcast afterwards and they'll still receive it. That's the whole argument.
Sequence delays are in days, and the first step defaults to 0 (arrives on join) while later steps default to 1. Which means a seeded series won't finish while you watch it, so the dashboard has Fast-forward the clock (local only): it pulls every pending step to now and runs a tick. Use it and you'll watch step 2 skip the people who left that series.
What's in here ๐
src/
worker.tsx fetch + scheduled + queue handlers โ the whole entry point, 151 lines
core/ domain logic: consent, sending, sequences, segments, rendering
db/ Drizzle schema (24 tables) and the D1 client
web/ server-rendered admin console (Hono + JSX, no frontend framework)
api/ transactional send API, signup forms, media upload, bearer-key auth
mcp/ MCP server โ 93 tools, 4 resources, 4 prompts
providers/ EmailProvider port + console and Resend adapters
client/ the only browser JS in the project: the TipTap editor bundle
migrations/ drizzle-kit generated, applied by wrangler
docs/ problem brief, architecture, spec, and a decision logRoughly 16k lines of TypeScript. bun run typecheck covers the Worker and the browser
bundle separately and is clean.
Architecture at a glance ๐งฑ
Cloudflare Workers ยท D1 (SQLite) via Drizzle ยท Queues for send fan-out ยท Cron Triggers
for scheduling ยท R2 for media ยท Hono + JSX server-rendered admin ยท Cloudflare Access for
auth ยท a pluggable EmailProvider port with console and Resend adapters.
The decisions worth knowing about, and why:
Every messages row is materialized before a single send goes out. A broadcast
resolves its full recipient list up front, writes a row per intended send, and only
then fans out to the queue. That makes a broadcast resumable after a crash, idempotent
across queue retries, and auditable afterward. Resolving recipients lazily at send time
is cheaper and turns any mid-broadcast failure into an unrecoverable mess.
Consent is re-checked immediately before the provider call, not at enqueue time. A queue can deliver minutes after the message was created, and someone can opt out in between. Checking at enqueue would mail them anyway.
Queue concurrency is pinned to 6. One batch is one provider request, so batch concurrency is the request rate. Left unset, Cloudflare Queues autoscales to 250 concurrent consumers, buries Resend's 10 req/s limit under 429s, burns all three retries, and dead-letters perfectly good mail. Six batches of 100 leaves ~600 emails/sec of headroom while staying under the limit.
Suppression is keyed by email address, not by subscriber. Transactional recipients
and bounced addresses often have no subscriber row at all, so a subscribers.status
flag would silently miss them.
Anything observable is a row in D1, never a log line. Workers logs expire in 3โ7
days. An audit trail with a one-week retention isn't one. mcp_calls records every
agent action including the refusals; sync_runs records every Stripe pull.
The admin console has no password. Cloudflare Access terminates identity at the
edge, and src/web/auth.ts verifies the forwarded JWT properly: signature against the
team's live JWKS (cached per isolate, with a forced refetch on an unknown key id),
alg pinned to RS256, plus audience, issuer, exp and nbf. Presence of the header
proves nothing and is never treated as proof. Misconfigure it and the middleware
fails closed and locks everyone out, including you. That's the correct direction to
fail.
The email HTML renderer is hand-written (core/render-doc.ts) rather than using
@tiptap/html, whose server entry point needs happy-dom and doesn't run inside
workerd. It turned out to be the better answer anyway: the walker inlines every style
(Gmail strips <style>) and emits nested tables for buttons (Outlook ignores padding
on <a>), which generic HTML serialization wouldn't do.
The full decision log, including the alternatives that were rejected and why, is in
docs/MEMORY.md.
Consent model ๐
Three independent scopes. A narrow choice never escalates to a wide one.
Scope | Stored as | Effect |
Sequence |
| Off that one series. Everything else continues. |
Broadcast |
| Off the newsletter. Series keep running. |
Global |
| Off everything. The legal escape hatch. |
Only an explicit "unsubscribe from everything", a hard bounce, or a complaint writes a global suppression.
Sequence sends deliberately ignore status = 'unsubscribed', because that flag is
broadcast-scoped: someone who left the newsletter still gets the onboarding series they
asked for. Transactional mail (receipts, downloads) ignores marketing consent entirely
and is blocked only by a dead address or a spam complaint. A receipt isn't marketing,
and an unsubscribed customer still needs their download.
The editor โ๏ธ
Block-style rich text, TipTap v3, vanilla (no React). Open the seeded draft "Draft: everything the editor can do" to see the lot.
/on a line โ block menu: headings, lists, checklist, quote, code, table, toggle, divider, image, YouTube, CTA button@โ personalization fields as real nodes, sofirst_namecan't be misspelledDrag the handle in the left margin to reorder; shift-select spans multiple blocks
Drop or paste an image anywhere โ uploads to R2, inserts when the URL returns
Code blocks are syntax-highlighted (15 languages, incl. Ruby, Elixir, TS, SQL)
Select text for the bubble menu; select a button and the bubble menu becomes its URL and colour picker
Buttons and merge tags are custom nodes built specifically for email. A CTA renders
as a nested table, and every style is inlined. A merge tag is a node rather than raw
{{first_name}} text, because a typo in raw text mails "Hi {{frist_name}}" to the
entire list.
Bodies are stored as TipTap JSON in body_json. Legacy markdown in body_md still
renders and is converted the moment you open it in the editor. Nothing is migrated in
bulk, because a bulk migration that goes wrong takes the archive with it.
The client bundle is ~226KB gzipped and loads only on the two screens that compose mail. Everything else in the admin console is server-rendered with zero JavaScript.
There's a browser smoke test (bun run smoke, 33 checks) driving real Chromium,
because a renamed extension option fails silently in the browser and the body field
just never saves. Nothing server-side can catch that.
Drive it from Claude Code ๐ค
The Worker serves an MCP server at POST /mcp/<secret>: 93 tools covering the
whole mailer, so an agent can cut segments, draft and send broadcasts, build sequences,
read campaign performance, and reconcile Stripe.
# 1. a path secret (this is what makes the endpoint exist at all)
openssl rand -hex 24 # โ put in .dev.vars as MCP_PATH_SECRET
# 2. an admin-scoped key โ POST /seed prints one, or use apikey_create
# 3. point Claude Code at it
claude mcp add --transport http --scope local \
--header "Authorization: Bearer $BIG_MAILER_KEY" \
big-mailer "http://localhost:8787/mcp/$MCP_PATH_SECRET"Three gates, cheapest first. An unguessable path secret compared in constant time
(a miss returns 404, not 403, because a URL nobody guessed should look like nothing
is there), then an admin-scoped bearer key (transactional send keys can't reach
it), then per-tool guards. Every call lands in mcp_calls, including the refusals.
Irreversible sends need a preflight. broadcast_send refuses without a token from
broadcast_preflight: single-use, 10-minute expiry, invalidated by any edit to the
content or the audience. Same for sequence_activate. On top of that, MCP_ALLOW_SEND
is "false" in production, so MCP can read and draft everything but cannot put mail on
the wire until you deliberately flip it. Flipping it back is the instant off-switch.
Agents should read bigmailer://conventions before touching consent. Scoped
unsubscribe is not the shape anything trained on normal ESPs expects, and getting it
wrong is exactly the failure this project was built to avoid.
Stripe โ campaign attribution ๐ณ
Set STRIPE_SECRET_KEY (restricted, read-only on charges/refunds/customers). A daily
cron at 09:17 UTC pulls new charges and credits each to the buyer's last attribution
touch, or to metadata.campaign when the charge carries one. Idempotent on the Stripe
charge id, so re-runs and overlapping backfills are harmless.
stripe_sync_preview dry-runs it, sales_unattributed is the worklist of what the
heuristic couldn't place, and sync_runs_list proves the nightly job is actually
running.
Commands โถ๏ธ
| Build the client bundle, then serve on :8787 |
| Rebuild the editor bundle on change (alongside |
| Browser smoke test of the editor. Needs |
| Apply migrations to local D1 |
| Generate a migration after editing |
| Drizzle Studio against the local database |
| Typechecks the Worker and the browser bundle separately |
| Build, then |
Sending for real ๐ฎ
Copy .dev.vars.example to .dev.vars, add a Resend key, and set
EMAIL_PROVIDER=resend. Point Resend's webhook at /webhooks/resend so bounces and
complaints suppress properly. Without that webhook, bad addresses never get suppressed
and your sending reputation degrades silently, which is the slow way to lose
deliverability for the whole domain.
Secrets go in .dev.vars (gitignored) or wrangler secret put. Never in
wrangler.jsonc, and never in .dev.vars.example.
โ ๏ธ Before deploying
Not deployed yet, and there is real setup between here and live:
DEV_AUTH_BYPASS=truesits in the top-levelwrangler.jsoncvars so the app is runnable locally.--env productionsets it false. A barewrangler deploypublishes an unauthenticated admin console, which is whybun run deployhard-codes--env production. Don't work around it.database_idis a placeholder. Create a real D1 database withwrangler d1 create.Create the R2 bucket (
big-mailer-media) and the two queues (big-mailer-send,big-mailer-dlq). Queues need the $5/mo Workers paid plan.Set
PUBLIC_URLto the real host. It's baked into tracking links and image URLs at send time, so a wrong value ships permanently broken email to mail that's already delivered. It cannot be fixed after the fact.MCP_PATH_SECRETmust be a real secret (wrangler secret put), not a var. Unset means the MCP endpoint 404s, which is the safe default. Turn it on deliberately.Cloudflare Access needs one Allow app and several Bypass apps. Protecting the whole hostname with a single Allow policy also protects the tracking pixel, signup forms, preference center, webhooks, and MCP, which means every tracking pixel in every email you send redirects to a login screen, permanently, for mail already delivered. Access matches the most specific path first, so
/t,/f,/p,/api,/webhooksand/mcpeach need their own Bypass app. Each of those carries its own auth or is public by design.No server-side test suite.
bun run smokecovers the editor.docs/SPEC.mdis written as numbered, testable requirements, shaped to be made executable.
Docs ๐
The problem, who it's for, and what's explicitly out of scope | |
System design, data model, and the send pipeline | |
Numbered behavioral requirements. The reference for intended behavior | |
Decision log: what was chosen, what was rejected, and why |
Contributing ๐ค
Bug reports, correctness fixes, and email-client rendering fixes are very welcome.
Multi-tenancy, a drag-and-drop builder, and self-run SMTP are out of scope on purpose.
See CONTRIBUTING.md before opening a PR, and
SECURITY.md if you've found a vulnerability (please report it
privately, not as an issue).
Forking is genuinely encouraged. This is small enough to read end to end and make your own. Participation is covered by the Code of Conduct.
License ๐
MIT ยฉ Rob Conery
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseCqualityBmaintenanceMCP server that exposes the complete Libredesk REST API (54 endpoints) as tools, enabling natural language management of conversations, contacts, agents, teams, and more for the open-source customer support desk.54113MIT
- AlicenseAqualityFmaintenanceAn MCP server for the Resend email API, enabling AI assistants to send emails, manage contacts, audiences, and domains through natural language.1844MIT

xmit-mcpofficial
FlicenseNot gradedqualityDmaintenanceRemote MCP server for the Transmit email platform, enabling email sending, contact management, template and campaign operations via natural language.- FlicenseCqualityDmaintenanceComprehensive MCP server for Mailchimp Marketing API v3.0 with over 104 tools and 15+ React UI apps, enabling management of campaigns, audiences, ecommerce, automations, reports, and more via natural language.1001
Related MCP Connectors
GibsonAI MCP server: manage your databases with natural language
Personal assistant MCP server with search, execute, packages, jobs, secrets, and integrations.
Markdown-first MCP server for Notion API with 8 composite tools and 39 actions.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/robconery/big-mailer'
If you have feedback or need assistance with the MCP directory API, please join our Discord server