Skip to main content
Glama
robconery

big-mailer

by robconery

Kōlea

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.

CI License: MIT TypeScript Cloudflare Workers

Install Β· Architecture Β· Spec Β· Contributing


🐦 Why Kōlea

The kōlea (koh-LEH-ah) β€” the Pacific golden plover β€” flies from Alaska to HawaiΚ»i every autumn. Three thousand miles of open ocean, nonstop, no land to rest on. Then it lands in the same yard it left, and does it again the next year, and the year after that.

Delivery, and the same address, every time. There isn't a better name for a mailer.


Status: feature-complete and runnable locally, and built for a single operator β€” not multi-tenant, by design rather than by omission. docs/INSTALL.md is the honest list of what stands between a clone and a live send.

The Kōlea dashboard, showing consent broken out by scope

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.


Related MCP server: Resend MCP Server

πŸ’‘ 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.


πŸš€ Install

Three commands, and it mails nobody.

git clone https://github.com/robconery/kolea.git
cd kolea
bun install
bun run db:migrate     # applies migrations to the local D1 database
bun run dev            # β†’ http://localhost:8787

Open 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. It writes fully rendered mail β€” footer, merge tags, tracking links and all β€” 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.

You need: Bun 1.1+. That's the whole list for local. No database to install, no Redis, no server β€” wrangler dev emulates D1, R2 and Queues locally.

Going live? β†’ docs/INSTALL.md walks the full deploy: D1, R2, queues, DNS and DMARC, the Cloudflare Access apps, secrets, and a pre-flight checklist to work through before you trust it with a list. It takes about an hour, most of it waiting for DNS, and there are two steps that are painful to undo.


🎯 Try the thing it's for

  1. Subscribers β†’ pick someone β†’ Open their preference center

  2. Add ?scope=sequence:2 to that URL. This is what a link inside a sequence email looks like.

  3. Hit Stop just this series

  4. Go back to their subscriber page: still active, still on the newsletter, out of exactly one series

  5. Consent 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.


πŸ“Š Measurement

An ESP will happily show you an open rate and let you draw your own conclusions. The five screens under Analytics exist because a mailing list is the one asset here you cannot inspect by looking at it: you can read every broadcast you ever wrote and still have no idea whether the list is healthy, and no amount of staring at a sequence tells you which mail in it loses people.

A sequence's step-by-step waterfall: how many got each mail, how many opened it, how many clicked, and where the drop-off is

The step waterfall for one sequence. Retention is measured against step one, never against the previous step β€” chained ratios hide a slow bleed across six mails behind six unremarkable-looking numbers. The line at the bottom names the biggest drop and what it usually means.

Screen

The question

Overview

Two dials β€” the health of the list, and the median score of the writing. A healthy list carrying weak sends and a weak list carrying great sends look identical on any single number, and they want opposite responses.

Sequences

Every series ranked, with the two things a rate can't tell you: who finishes, and what it earned. Flags series switched off with people still inside them.

Broadcasts

Every send scored against your own median, which is the only benchmark that survives contact with reality.

Contribution

Which mail actually moved a goal β€” by channel, and by the individual broadcast or sequence that earned the credit.

List health

Growth, engagement, churn, delivery and money, each with the sentence saying what to do about it.

Every figure on these screens carries the two integers underneath it, because a rate with no denominator is how dashboards mislead people who trust them. Nothing here writes β€” no forms, no POST routes, by construction. An analytics page that can send mail is one misclick from mailing the whole list.

bun scripts/seed-analytics-demo.ts     # local only, and there is no --remote flag

That seeds a world worth looking at: demo people spread over a year, two live sequences with real message and event history, imported broadcasts, conversions and a couple of goals. --clean removes every row of it again.


πŸ—‚ What's in here

src/
  worker.tsx      fetch + scheduled + queue handlers β€” the whole entry point, 177 lines
  core/           domain logic: consent, sending, sequences, segments, rendering,
                  scoring (signal.ts) and measurement (analytics.ts)
  db/             Drizzle schema (32 tables) and the D1 client
  web/            server-rendered admin console (Hono + JSX, no frontend framework),
                  including the five read-only analytics screens
  api/            transactional send API, signup forms, media upload, bearer-key auth
  mcp/            MCP server β€” 96 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
scripts/          list importers and the browser smoke test
docs/             install guide, architecture, spec, and a decision log

Roughly 30k lines of TypeScript. bun run typecheck covers the Worker, the browser bundle, and the scripts 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.

One scorer, two subjects. A broadcast and a sequence are graded by the same instrument (core/signal.ts), so a sequence's 62 and a broadcast's 62 are the same 62 and "is this series better than my newsletter?" is a question with an answer. Every rate in the system divides by people reached β€” recipients minus bounces β€” and never by delivered: provider delivered webhooks cover a fraction of what goes out, and dividing by them reports open rates above 100%.

A number that could never have been measured is null, not 0. Mail imported from a previous ESP has no per-recipient history here and never will, so no click could be recorded and no conversion could ever attach to it. Those rows drop the MONEY component and renormalize the rest, rather than scoring a decade of good work as a zero on something that was never measurable. Same reflex everywhere: a sequence under 30 people reached is unscored, because a 100% click rate across eight people is noise wearing a suit.

Attribution is last-click, inside a per-source window. An open is never a touch β€” Apple's Mail Privacy Protection fires opens from proxies, so crediting them hands revenue to whoever mailed most recently. Anything with no click in the window is direct, which is the honest answer for most sales on most lists and is drawn as an ordinary result rather than a hole in the data.

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 picture, written to be read by a person or an agent, is in docs/ARCHITECTURE.md. The decision log β€” including the alternatives that were rejected and why β€” is in docs/MEMORY.md.


Three independent scopes. A narrow choice never escalates to a wide one.

Scope

Stored as

Effect

Sequence

sequence_optouts row

Off that one series. Everything else continues.

Broadcast

subscribers.status

Off the newsletter. Series keep running.

Global

suppressions row

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, so first_name can't be misspelled

  • Drag 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>: 96 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 /dev/seed prints one, or use apikey_create

# 3. point Claude Code at it
claude mcp add --transport http --scope local \
  --header "Authorization: Bearer $KOLEA_KEY" \
  kolea "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 kolea://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.

πŸ“– docs/ARCHITECTURE.md is written as an agent's reference β€” the invariants, the code map, the traps, and where to add things.


πŸ’³ Stripe β†’ campaign attribution

Set STRIPE_SECRET_KEY (restricted, read-only on charges/refunds/customers) and STRIPE_WEBHOOK_SECRET, then point a Stripe webhook at /webhooks/stripe. A charge becomes an attributed sale within seconds, credited to the buyer's last attribution touch or to metadata.campaign when the charge carries one.

A daily cron at 09:17 UTC walks the same charges again as a safety net. Everything is 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.


πŸ”§ Make it yours

Swapping the mail provider is one file. Resend is the first adapter, not a dependency β€” nothing in core/ names a vendor, anywhere. That's deliberate: deliverability is the risk most likely to sink a self-hosted mailer, so the escape hatch had to be cheap. If Resend's reputation goes sideways, or you'd rather be on SES because you're already in AWS, the diff is one adapter.

The whole port is four members:

export interface EmailProvider {
  readonly name: string
  send(email: OutgoingEmail): Promise<SendResult>
  /** Providers rate-limit on API *requests* β€” this is the difference between a
      15,000-email broadcast taking 25 minutes and taking seconds. */
  sendBatch(emails: OutgoingEmail[]): Promise<SendResult[]>
  /** Verify + parse a provider webhook. Returns [] if it isn't ours to handle. */
  parseWebhook(request: Request, secret?: string): Promise<ProviderEvent[]>
}

Three steps to add Amazon SES, Mailgun, Postmark, Cloudflare Email Service, or anything else with an HTTP API:

  1. Write src/providers/ses.ts implementing the interface above. Copy resend.ts β€” it's 191 lines and it's the reference.

  2. Add a branch to providerFor() in src/core/sending.ts (it's three lines).

  3. Set EMAIL_PROVIDER=ses and push whatever key it needs with wrangler secret put.

That's it. Broadcasts, sequences, transactional sends, consent, suppression and tracking all keep working untouched, because none of them know a provider exists.

πŸ€– Or just ask Claude Code to write it

The port is small enough, and resend.ts is a close enough template, that this is genuinely a single prompt:

Read src/providers/types.ts and src/providers/resend.ts, then write src/providers/ses.ts implementing EmailProvider against the Amazon SES v2 API. Wire it into providerFor() in src/core/sending.ts and add the env vars to src/types.ts. Map SES bounce and complaint notifications onto ProviderEvent.

Then check it with bun run typecheck, run it with EMAIL_PROVIDER=console first, and read the Outbox before you point it at a real key.

⚠️ Don't skip parseWebhook. It's what turns hard bounces and spam complaints into suppressions rows. An adapter that only sends will happily keep mailing dead addresses and people who reported you, and your sending reputation degrades silently β€” the slow way to lose deliverability for your whole domain. Soft bounces must not suppress; that's what hardBounce on ProviderEvent is for.

Other things worth changing

Want to change

Look at

The look

src/web/layout.tsx β€” the whole "Abyssal" design system is one file of CSS tokens

What an email looks like on the wire

src/core/render-doc.ts β€” the hand-written TipTap β†’ email-HTML walker

Editor blocks

src/client/extensions/ for the node, and a matching branch in render-doc.ts, or it renders as nothing in email

Who a segment can target

SegmentRule in src/db/schema.ts, resolved in src/core/segments.ts

What agents can do

src/mcp/tools/*.ts β€” thin wrappers, so add the rule to core/ first

How a send is scored

ANCHORS and WEIGHTS in src/core/signal.ts β€” the anchors are what a good rate looks like on your list

What the analytics screens measure

src/core/analytics.ts β€” the pages are dumb and just draw what it returns

πŸ“– docs/ARCHITECTURE.md has a full "where to add things" table, plus the invariants you must not break while doing it.


▢️ Commands

bun run dev

Build the client bundle, then serve on :8787

bun run watch:client

Rebuild the editor bundle on change (alongside dev)

bun run smoke

Browser smoke test of the editor. Needs dev running

bun scripts/seed-analytics-demo.ts

Fill the analytics screens with local demo data (--clean to undo)

bun run db:migrate

Apply migrations to local D1

bun run db:generate

Generate a migration after editing src/db/schema.ts

bun run db:studio

Drizzle Studio against the local database

bun run typecheck

Worker, browser bundle and scripts, separately

bun run deploy

Build, then wrangler deploy --env production. Read INSTALL first


πŸ“š Docs

docs/INSTALL.md

πŸ›  Local setup, full production deploy, integrations, troubleshooting

docs/ARCHITECTURE.md

πŸ— System design, invariants, code map. Written for an LLM to read before changing anything

docs/SPEC.md

πŸ“ Numbered behavioral requirements. The reference for intended behavior

docs/PROJECT.md

🎯 The problem, who it's for, and what's explicitly out of scope

docs/MEMORY.md

🧠 Decision log: what was chosen, what was rejected, and why

docs/PLAN.md Β· docs/STORIES.md

βœ… Build status and the (thin) backlog


🀝 Contributing

Bug reports, correctness fixes, and email-client rendering fixes are very welcome. The highest-value contribution available is making docs/SPEC.md executable β€” it's written as numbered, testable requirements precisely so it can become a test suite.

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

A
license - permissive license
Not graded
quality - not tested
B
maintenance

Maintenance

–Maintainers
–Response time
–Release cycle
–Releases (12mo)
Commit activity

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

  • A
    license
    C
    quality
    B
    maintenance
    MCP 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.
    54
    11
    3
    MIT
  • A
    license
    A
    quality
    F
    maintenance
    An MCP server for the Resend email API, enabling AI assistants to send emails, manage contacts, audiences, and domains through natural language.
    18
    44
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Remote MCP server for the Transmit email platform, enabling email sending, contact management, template and campaign operations via natural language.
  • F
    license
    C
    quality
    D
    maintenance
    Comprehensive 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.
    100
    1

View all related MCP servers

Related MCP Connectors

  • GibsonAI MCP server: manage your databases with natural language

  • Personal assistant MCP server with search, execute, packages, jobs, secrets, and integrations.

  • Hosted MCP server for MuntuAI outreach campaigns, leads, senders, domains, and analytics.

View all MCP Connectors

Latest Blog Posts

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/kolea'

If you have feedback or need assistance with the MCP directory API, please join our Discord server