[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"project-81777":3},{"id":4,"name":5,"fullName":6,"owner":7,"repo":5,"description":8,"homepage":9,"htmlUrl":10,"language":11,"languages":10,"totalLinesOfCode":10,"stars":12,"forks":13,"watchers":14,"openIssues":13,"contributorsCount":15,"subscribersCount":15,"size":15,"stars1d":15,"stars7d":13,"stars30d":16,"stars90d":15,"forks30d":15,"starsTrendScore":15,"compositeScore":17,"rankGlobal":10,"rankLanguage":10,"license":18,"archived":19,"fork":19,"defaultBranch":20,"hasWiki":21,"hasPages":19,"topics":22,"createdAt":10,"pushedAt":10,"updatedAt":23,"readmeContent":24,"aiSummary":25,"trendingCount":15,"starSnapshotCount":15,"syncStatus":16,"lastSyncTime":26,"discoverSource":27},81777,"minigun","ranaroussi\u002Fminigun","ranaroussi","A tiny, self-hosted email sender on top of Mailgun and Cloudflare","https:\u002F\u002Fx.com\u002Faroussi\u002Fstatus\u002F2059674507879612926",null,"Go",23,1,21,0,2,41.6,"MIT License",false,"main",true,[],"2026-06-12 04:01:35","\u003Cimg width=\"1720\" height=\"688\" alt=\"image\" src=\"https:\u002F\u002Fgithub.com\u002Fuser-attachments\u002Fassets\u002F039d9c6d-7488-49ac-a791-621f36122c95\" \u002F>\n\n# MiniGun\n\n> A tiny self-hosted email layer on top of [Mailgun](https:\u002F\u002Fwww.mailgun.com). Write in **Markdown**, drive it from a **CLI** or any **AI client over MCP**, deploy it to **Cloudflare's edge** — zero infra, no Redis, no queue, no long-running process.\n\n[![License: MIT](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002Flicense-MIT-blue.svg)](.\u002FLICENSE)\n[![Go](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FGo-1.25-00ADD8?logo=go&logoColor=white)](.\u002Fsrc)\n[![Cloudflare Workers](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FCloudflare-Workers%20%2B%20D1-F38020?logo=cloudflare&logoColor=white)](.\u002Fdocs\u002Fcloudflare.md)\n[![MCP](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FMCP-ready-111?logo=anthropic&logoColor=white)](.\u002Fdocs\u002Fcli.md#mcp-server-minigun-mcp)\n[![SDKs](https:\u002F\u002Fimg.shields.io\u002Fbadge\u002FSDKs-PHP%20·%20Python%20·%20TS%20·%20Go-success)](.\u002Fsdks)\n\nAlso packaged as a single Go binary or a Docker container if you'd rather host it yourself.\n\n**Mailgun does delivery, tracking, and deliverability. MiniGun owns everything else you'd otherwise glue together** — contacts, lists, unsubscribe pages, scheduling, crash recovery, stats persistence, and list cleanup.\n\n**Jump to:** [Quickstart](#quickstart) · [CLI](#a-first-class-cli) · [Deploy to Cloudflare](.\u002Fdocs\u002Fcloudflare.md) · [Drive it with an AI](#mcp-server--drive-it-with-an-ai) · [SDKs](#sdks) · [API](#api)\n\n---\n\n## Contents\n\n- [Features](#features)\n- [Quickstart](#quickstart)\n- [Install](#install)\n- [Your first newsletter](#your-first-newsletter)\n- [Why MiniGun](#why-minigun)\n- [What makes MiniGun different](#what-makes-minigun-different)\n- [Architecture](#architecture)\n- [SDKs](#sdks)\n- [Agent skill](#agent-skill)\n- [API](#api)\n- [Configuration](#configuration)\n- [Development](#development)\n- [Project layout](#project-layout)\n- [License](#license)\n\n## Features\n\n| Feature | What you get | Deep dive |\n|---|---|---|\n| **Markdown authoring** | Write emails in Markdown with `{{first_name \\| \"there\"}}` variable defaults. Rendered to HTML + text, no MJML, no HTML builder, no second template language. | [below](#markdown-first-authoring) |\n| **Crash-safe bulk sends** | 100k-recipient sends that survive worker restarts and resume from the last completed batch — on a Cloudflare Worker with no long-running process. | [below](#bulk-email-on-the-cloudflare-edge) |\n| **Scheduled sends** | `--send-at \u003CRFC3339>` parks a bulk or single send until a background dispatcher fires it. No Mailgun 3-day cap; `send cancel` unschedules it. Bulk audience resolves at dispatch, so late signups are included. | [docs\u002Fcli.md](.\u002Fdocs\u002Fcli.md#minigun-send-bulk) |\n| **Automatic list hygiene** | Hard bounces and spam complaints purge themselves in real time via a signed Mailgun webhook; an engagement-based prune unsubscribes dormant contacts on three configurable signals. Your list self-heals. | [docs\u002Flist-hygiene.md](.\u002Fdocs\u002Flist-hygiene.md) |\n| **Engagement rollups & segmentation** | Per-`(send, contact)` and per-`(contact, list)` rollups + a per-URL click tier that outlive Mailgun's 5-day retention. Segment by who opened\u002Fclicked what; powers prune-by-engagement. | [docs\u002Fevents-archive.md](.\u002Fdocs\u002Fevents-archive.md) |\n| **HMAC unsubscribe tokens** | Stateless tokens — no DB lookup to verify, no Mailgun suppression-list lock-in. You own the unsub flow forever. | [below](#what-makes-minigun-different) |\n| **Edge-native** | Same HTTP API, schema, and token format as the Go server, but as a Cloudflare Worker on D1. No process to crash. | [docs\u002Fcloudflare.md](.\u002Fdocs\u002Fcloudflare.md) |\n| **First-class CLI** | One `go install`, every server operation behind sensible flags, `--watch` mode for tailing in-flight sends. | [docs\u002Fcli.md](.\u002Fdocs\u002Fcli.md) |\n| **Agent-ready** | MCP server + a deep operator [skill](.\u002Fskill\u002Fminigun\u002FSKILL.md): dispatch rules, IP warming, DMARC graduation, anti-patterns to push back on. | [below](#mcp-server--drive-it-with-an-ai) |\n| **Zero-dep SDKs** | Single drop-in files for PHP \u002F Python \u002F TypeScript \u002F Go — no `axios` \u002F `requests` \u002F `guzzle`. | [sdks\u002F](.\u002Fsdks) |\n\n## Quickstart\n\nInstall the CLI:\n\n```bash\ngo install github.com\u002Franaroussi\u002Fminigun\u002Fcli\u002Fcmd\u002Fminigun@latest\n```\n\nIf you already have a MiniGun deployment (or are running one locally via `wrangler dev`), verify it end-to-end with Mailgun's `testmode` — the message is accepted, logged, and counted, but **never delivered**:\n\n```bash\nexport MINIGUN_API_URL=https:\u002F\u002Fmailer.example.com\nexport MINIGUN_API_TOKEN=...\n\nminigun health                                            # is the worker up?\nminigun send single --testmode \\\n  --to you@example.com \\\n  --from \"You \u003Cyou@example.com>\" \\\n  --subject \"MiniGun smoke test\" \\\n  --company acme \\\n  --md \"Hi {{first_name | 'there'}}, this is a test.\"\n```\n\nConfirm the send appears in your Mailgun dashboard (Sending → Logs) and you've verified every layer — DNS, auth, the worker, Mailgun — without spamming yourself.\n\n## Install\n\nDon't have a MiniGun deployment yet? Pick one of three install paths — every one is documented:\n\n| Target | When to pick it | Walkthrough |\n|---|---|---|\n| **Cloudflare Worker + D1** | Zero infra. Free for any volume Mailgun's free tier supports. Bulk sends survive crashes because there's no process to crash. | [docs\u002Fcloudflare.md](.\u002Fdocs\u002Fcloudflare.md) |\n| **Go binary** | On-prem, single VM, or you want a long-running process you can `systemctl` around. | [docs\u002Fbinary.md](.\u002Fdocs\u002Fbinary.md) |\n| **Docker \u002F Compose** | Containerised stack; one-line `docker run` and you're up. | [docs\u002Fdocker.md](.\u002Fdocs\u002Fdocker.md) |\n\n## Your first newsletter\n\nThe full arc — create a list, add a subscriber, write copy in Markdown, send to the list:\n\n```bash\nminigun list create  --name \"Weekly\" --slug weekly\nminigun contact add  weekly alice@example.com --params '{\"first_name\":\"Alice\"}'\n\ncat > week-12.md \u003C\u003C'EOF'\nHi {{first_name | \"there\"}},\n\nBig news this week — here's the [full update](https:\u002F\u002Fexample.com\u002Fblog\u002F12).\n\nCheers,\nRan\nEOF\n\nminigun send bulk --list weekly --subject \"Weekly update\" \\\n  --from \"Ran \u003Cran@example.com>\" --md .\u002Fweek-12.md\n```\n\nThe `POST \u002Fsend\u002Fbulk` returns a `send_id` immediately and a background loop drives the batches. Poll status with `minigun send status \u003Cid> --watch` and stats with `minigun send stats \u003Cid>` — persisted locally forever, even after Mailgun's 5-day event log retention expires. Add `--send-at 2026-06-01T09:00:00Z` to schedule it instead.\n\n## Why MiniGun\n\nMailgun is excellent at sending email. It is not opinionated about how you store contacts, how recipients unsubscribe, or how you resume a bulk send after a crash — those are your problem. MiniGun is the thin layer that solves them and stays out of the way.\n\n**Mailgun keeps owning what it's good at:** delivery, open\u002Fclick tracking, deliverability, bounce handling, reputation, IP warmup.\n\n**MiniGun owns the parts you'd otherwise glue together by hand:**\n\n- **Contacts and lists**, in SQLite (or D1), with arbitrary per-contact JSON metadata.\n- **Unsubscribe links you actually own** — stateless HMAC tokens, no DB lookup to verify, no Mailgun suppression-list lock-in. Backed by a two-step CAPTCHA-protected unsubscribe page so email security scanners (Microsoft Defender, Gmail link inspection, Apple Mail Privacy Protection) don't silently unsubscribe recipients by pre-fetching the link. RFC 2369 + RFC 8058 one-click headers on every bulk send (required by Gmail\u002FYahoo bulk-sender rules).\n- **Crash-safe bulk sends.** Each batch is checkpointed by a monotonic subscription id; if the process dies mid-send you resume from where you left off. Recipients added during a send don't get pulled in mid-flight.\n- **Scheduled sends.** `--send-at \u003CRFC3339>` (bulk or single) parks a send in a `scheduled` status until a background dispatcher fires it — no Mailgun-side deferral, so no 3-day cap, and `minigun send cancel \u003Cid>` unschedules it with a single guarded status flip. A scheduled *bulk* send resolves its recipient set when it fires, so contacts who subscribe before then are included (additions during the send itself are still excluded).\n- **Companies \u002F brands** group related lists so a recipient on the *Acme Newsletter* and *Acme Product Updates* lists sees a single combined preferences page.\n- **Permanent per-send stats.** Mailgun retains event logs for only 5 days; MiniGun pulls the Metrics API on a front-loaded schedule (+0, +1h, +6h, +24h, +48h, +5d after completion) and persists the aggregates locally.\n- **Clean Gmail rendering** on cross-domain `From` headers. MiniGun always sets `Sender: \u003CFrom>` so Mailgun doesn't rewrite it to a VERP bounce address (which is what makes Gmail show `via mailgun-route.example.com` and hide the native one-click unsubscribe).\n- **Dry-run sends.** `--testmode` runs the full pipeline through to Mailgun with `o:testmode=yes`: accepted and logged but not delivered. Persisted on the send row, so cron-resumed chains keep it set.\n- **Automatic list hygiene.** A Mailgun-signed webhook auto-removes hard-bouncing addresses and spam-complainers in real time, plus an engagement-based prune for the dormant middle ground. See [docs\u002Flist-hygiene.md](.\u002Fdocs\u002Flist-hygiene.md).\n\nThe philosophy:\n\n> Keep sending simple. Let Mailgun do the heavy lifting. Own your contacts and unsubscribe flow.\n\n## What makes MiniGun different\n\n### Bulk email on the Cloudflare edge\n\nThe same HTTP API, the same database schema, the same HMAC token format as the Go server — but as a Cloudflare Worker backed by D1. **No long-running process.** The bulk-send loop is a chain of self-invoking HTTP requests guarded by an atomic D1 batch claim: each step processes one batch and `ctx.waitUntil(fetch(\u002Fnext))`s the next. A once-a-minute cron sweeps any send whose chain has gone quiet (and dispatches due scheduled sends), so even a worker crash recovers without your involvement. The same cron refreshes per-send stats on a front-loaded schedule so historical sends keep their numbers forever.\n\nTokens signed by the Go server verify on the Worker, and vice-versa: both sides use the same HMAC-SHA256 wire format over `crypto\u002Fhmac` and Web Crypto. You can run them side-by-side, or migrate in either direction, without invalidating a single unsubscribe link.\n\n→ [docs\u002Fcloudflare.md](.\u002Fdocs\u002Fcloudflare.md)\n\n### A first-class CLI\n\nA single `minigun` command — installable with one `go install` — that exposes every server operation with sensible flags, JSON I\u002FO, and a `--watch` mode for tailing in-flight sends.\n\n```bash\nminigun health\nminigun list    create     --name \"Weekly\" --slug weekly\nminigun contact add        weekly ran@example.com --params '{\"first_name\":\"Ran\"}'\nminigun contact delete     bounced@example.com    # hard-bounce purge (or by c_xxxx id)\nminigun send    bulk       --list weekly --subject \"Hi\" --from \"Ran \u003Cr@x.com>\" --md .\u002Femail.md\nminigun send    bulk       --list weekly --subject \"Hi\" --from \"Ran \u003Cr@x.com>\" --md .\u002Femail.md --send-at 2026-06-01T09:00:00Z\nminigun send    cancel     s_xxxx          # unschedule a scheduled\u002Fqueued send\nminigun send    status     s_xxxx --watch\nminigun send    stats      s_xxxx\nminigun send    resume     s_xxxx          # crash-safe; --force after an in-flight batch\n```\n\nConfiguration is just two env vars (`MINIGUN_API_URL`, `MINIGUN_API_TOKEN`) so it slots into whatever shell\u002FCI you already use.\n\n→ [docs\u002Fcli.md](.\u002Fdocs\u002Fcli.md)\n\n### MCP server — drive it with an AI\n\nThe exact same `minigun` binary doubles as a [Model Context Protocol](https:\u002F\u002Fmodelcontextprotocol.io) server over stdio. Every CLI operation is exposed as an MCP tool; lists, contacts, and sends as MCP resources. Built on the official Go MCP SDK.\n\nWire it into Claude Desktop, Cursor, Zed, Continue, Goose, or anything else that speaks MCP:\n\n```json\n{\n  \"mcpServers\": {\n    \"minigun\": {\n      \"command\": \"minigun\",\n      \"args\": [\"mcp\"],\n      \"env\": {\n        \"MINIGUN_API_URL\": \"https:\u002F\u002Fmailer.example.com\",\n        \"MINIGUN_API_TOKEN\": \"...\"\n      }\n    }\n  }\n}\n```\n\nThen ask your model in plain English:\n\n> *\"How did last Tuesday's send to the 'weekly' list perform? Draft a follow-up to anyone who opened it but didn't click.\"*\n\nDestructive tools (`send_bulk`, `send_single`, `unsubscribe_contact`, `resume_send`, `cancel_send`) are tagged so the client renders an explicit confirmation prompt. Two built-in **prompts** (`compose_newsletter`, `audit_send`) encode the two most common operator workflows.\n\n→ [docs\u002Fcli.md](.\u002Fdocs\u002Fcli.md#mcp-server-minigun-mcp)\n\n### Markdown-first authoring\n\nWrite your email in plain Markdown:\n\n```markdown\nHi {{first_name | \"there\"}},\n\nBig news this week — here's the [full update](https:\u002F\u002Fexample.com\u002Fblog\u002F12).\n\nCheers,\nRan\n```\n\nMiniGun renders it to both HTML and plain text, rewrites `{{first_name | \"there\"}}`-style placeholders into Mailgun recipient variables, and — if you didn't include one — auto-injects an unsubscribe footer in both versions that resolves to your per-recipient HMAC token:\n\n```text\nHTML: \u003Cp>&nbsp;\u003Cbr>\u003Ca href=\"{{unsubscribe}}\">Unsubscribe\u003C\u002Fa>\u003C\u002Fp>\nText: Unsubscribe:\n      {{unsubscribe}}\n```\n\nNo MJML. No HTML email builder. No second template language. Just Markdown.\n\n### Automatic list hygiene\n\nThe single biggest reason newsletter senders trash their reputation is mailing addresses that no longer exist (hard bounces), actively flagged the previous send as spam, or keep receiving messages they never engage with. MiniGun handles all three automatically:\n\n- **Reactive** — a Mailgun-signed webhook (`POST \u002Fwebhooks\u002Fmailgun`) auto-purges hard bounces and spam complaints in real time. HMAC-verified, replay-bounded, idempotent against Mailgun's retries. Fails closed (401) when no signing key is configured.\n- **Proactive** — `POST \u002Flists\u002F{list}\u002Fprune` unsubscribes the dormant middle ground (addresses that exist and don't complain but never engage) on three OR'd, `dry_run`-by-default signals, with an opt-in daily auto-prune cron.\n\n→ Full reference, setup, and the hard-purge \u002F prune command surfaces: **[docs\u002Flist-hygiene.md](.\u002Fdocs\u002Flist-hygiene.md)**\n\n### Engagement rollups & segmentation\n\nWith `EVENTS_ARCHIVE_ENABLED=true`, MiniGun pulls Mailgun's events API on a burst-then-daily schedule for 30 days and folds each event into bounded rollups — **no raw per-event log**:\n\n- **`contact_message_engagement`** — per-`(send, contact)`: sent\u002Fdelivered, first\u002Flast open + click with counts, failure\u002Fcomplaint\u002Funsubscribe state.\n- **`contact_engagement`** — per-`(contact, list)` lifetime summary that powers prune-by-engagement.\n- **`contact_message_clicks`** — per-`(send, contact, url)` click rollup with canonicalized URLs, for \"who clicked this link\" segmentation.\n\nThese outlive Mailgun's 5-day retention and are exposed over HTTP, MCP, and the SDKs (`minigun send recipients`, `minigun send clicks`, `minigun contact engagement`). Opt-in: the schema ships dormant until you flip the flag.\n\n→ Tiers, read endpoints, and operational guarantees: **[docs\u002Fevents-archive.md](.\u002Fdocs\u002Fevents-archive.md)**\n\n## Architecture\n\n```\nyour app  ──┐\n            ├──►  MiniGun API  ──►  SQLite or D1\nCLI \u002F MCP ──┘            │\n                         ▼\n                       Mailgun API  (delivery, tracking, deliverability)\n```\n\nOne HTTP service, two implementations (Go binary, Cloudflare Worker), one shared schema, one shared token format. Bulk sends are always async: `POST \u002Fsend\u002Fbulk` returns a `send_id` immediately while a background loop (goroutine in the Go server, self-invoking HTTP chain in the Worker) drives the batches forward. Scheduled sends are parked until a dispatcher (a 30s ticker in the Go server, the per-minute cron in the Worker) fires them through the same path.\n\n## SDKs\n\nSingle-file, zero-dependency drop-in clients for the most common server languages. Every SDK exposes the same surface (contacts, sends, scheduling\u002Fcancel, status, stats, resume, delete) with the same error model — pick whichever fits your stack:\n\n| Language | File | Drop in \u002F install | Reference |\n|---|---|---|---|\n| **PHP** (7.4+) | [`sdks\u002Fphp\u002Fminigun.php`](.\u002Fsdks\u002Fphp\u002Fminigun.php) | `require_once 'minigun.php';` | [sdks\u002Fphp\u002FREADME.md](.\u002Fsdks\u002Fphp\u002FREADME.md) |\n| **Python** (3.9+) | [`sdks\u002Fpython\u002Fminigun.py`](.\u002Fsdks\u002Fpython\u002Fminigun.py) | drop the file in, `from minigun import Minigun` | [sdks\u002Fpython\u002FREADME.md](.\u002Fsdks\u002Fpython\u002FREADME.md) |\n| **TypeScript** (ES2020+) | [`sdks\u002Ftypescript\u002Fminigun.ts`](.\u002Fsdks\u002Ftypescript\u002Fminigun.ts) | drop the file in, `import { Minigun } from '.\u002Fminigun'` | [sdks\u002Ftypescript\u002FREADME.md](.\u002Fsdks\u002Ftypescript\u002FREADME.md) |\n| **Go** (1.21+) | [`sdks\u002Fgo\u002Fminigun.go`](.\u002Fsdks\u002Fgo\u002Fminigun.go) | `go get github.com\u002Franaroussi\u002Fminigun\u002Fsdks\u002Fgo` | [sdks\u002Fgo\u002FREADME.md](.\u002Fsdks\u002Fgo\u002FREADME.md) |\n\nAll four are stdlib-only — no `requests`, no `axios`, no `guzzle`, no external Go modules — so they slot into any project without touching its dependency tree. The TypeScript SDK uses the standard `fetch` API, so it runs unchanged on Node 18+, Bun, Deno, Cloudflare Workers, and the browser.\n\nSee [sdks\u002FREADME.md](.\u002Fsdks\u002FREADME.md) for the cross-language overview and the matching method-name table.\n\n## Agent skill\n\nFor [Factory Droid](https:\u002F\u002Ffactory.ai) (and any AI client that loads external context files), this repo ships a complete operator skill at [`skill\u002Fminigun\u002FSKILL.md`](.\u002Fskill\u002Fminigun\u002FSKILL.md). It's not a CLI wrapper — it's a deep playbook covering: how to pick `send_single` vs `send_bulk`, the pre-send checklist, post-send polling, failure recovery, the IP warming schedule, DMARC graduation, content red flags, list hygiene, and the anti-patterns to push back on.\n\nInstall once with a symlink to keep it in sync as the repo evolves:\n\n```bash\nmkdir -p ~\u002F.factory\u002Fskills\nln -s \"$(pwd)\u002Fskill\u002Fminigun\" ~\u002F.factory\u002Fskills\u002Fminigun\n```\n\nPair it with the MiniGun MCP server (covered above under *MCP server — drive it with an AI*) for full autonomy — the skill teaches the playbook, the MCP server gives the agent hands.\n\n→ [skill\u002FREADME.md](.\u002Fskill\u002FREADME.md) for install \u002F usage from other AI clients.\n\n## API\n\nThe server speaks JSON over HTTP on `:8080`. When `MINIGUN_API_TOKEN` is set, all routes require `Authorization: Bearer \u003Ctoken>` except `\u002Fhealthz`, `\u002Fu\u002F{token}`, `\u002Fmanage\u002F{token}`, and `\u002Fwebhooks\u002F*` (the unsubscribe \u002F manage routes carry their own HMAC token in the URL; the webhook routes are HMAC-verified per-request against the Mailgun signing key).\n\n| Method | Path                                       | Purpose |\n|--------|--------------------------------------------|---------|\n| GET    | `\u002Fhealthz`                                 | Health probe (DB ping). |\n| GET    | `\u002Fcompanies`                               | List all companies. |\n| POST   | `\u002Fcompanies`                               | Create a company. |\n| GET    | `\u002Fcompanies\u002F{company}`                     | One company by id or slug. |\n| GET    | `\u002Fcompanies\u002F{company}\u002Flists`               | All lists belonging to a company. |\n| GET    | `\u002Flists`                                   | List all lists with `subscribed_count`. |\n| POST   | `\u002Flists`                                   | Create a list (optionally bound to a company). |\n| GET    | `\u002Flists\u002F{list}`                            | One list with `subscribed_count`, `total_count`, `last_send_at`. |\n| GET    | `\u002Flists\u002F{list}\u002Fcontacts?cursor=&limit=`    | Paginated contacts for a list. |\n| POST   | `\u002Flists\u002F{list}\u002Fcontacts`                   | Upsert contact + subscription. |\n| POST   | `\u002Flists\u002F{list}\u002Funsubscribe`                | Admin unsubscribe by email (keeps row, marks `subscribed=0`). |\n| DELETE | `\u002Fcontacts\u002F{idOrEmail}`                    | Hard-delete a contact + all subscriptions + audit rows (hard-bounce cleanup). |\n| GET    | `\u002Fsends?cursor=&limit=`                    | Paginated send history (created_at desc). |\n| POST   | `\u002Fsend\u002Fbulk`                               | Start a bulk send. Optional `send_at` (RFC3339) schedules it for later. |\n| POST   | `\u002Fsend\u002Fsingle`                             | Send a single transactional email. Optional `send_at` (RFC3339) schedules it for later. |\n| POST   | `\u002Fsend\u002F{id}\u002Fnext`                          | Execute the next batch step (chain self-call; alias of `\u002Fresume`). |\n| POST   | `\u002Fsend\u002F{id}\u002Fresume`                        | Resume a paused \u002F failed send (alias of `\u002Fnext`). |\n| POST   | `\u002Fsend\u002F{id}\u002Fcancel`                        | Unschedule a `scheduled`\u002F`queued` send (→ `cancelled`). `409` once running or terminal. |\n| GET    | `\u002Fsend\u002F{id}`                               | Send status + progress. |\n| GET    | `\u002Fsend\u002F{id}\u002Fstats`                         | Aggregate stats (DB-backed; falls back to live Mailgun for fresh sends). |\n| GET    | `\u002Fsend\u002F{id}\u002Frecipients?limit=&cursor=` | Per-recipient message engagement rollup for a send (one row per contact; keyset-paginated by `contact_id`). Requires `EVENTS_ARCHIVE_ENABLED=true`. |\n| GET    | `\u002Fsend\u002F{id}\u002Fclicks?limit=&cursor=`         | Per-URL click rollup for a send (one row per contact + clicked link). Requires `EVENTS_ARCHIVE_ENABLED=true`. |\n| GET    | `\u002Fcontacts\u002F{idOrEmail}\u002Fengagement?list_id=` | Contact's per-list lifetime engagement summary (totals + last open\u002Fclick + dormancy counter). Requires `EVENTS_ARCHIVE_ENABLED=true`. |\n| POST   | `\u002Flists\u002F{list}\u002Fprune`                      | Engagement-based prune. Body accepts `min_messages_since_engagement`, `dormant_for_days`, `no_delivery_for_days`, `dry_run` (default true), `limit`, `sample_size`. |\n| GET    | `\u002Fu\u002F{token}`                               | Render the unsubscribe confirmation page. |\n| POST   | `\u002Fu\u002F{token}`                               | Perform the unsubscribe (form post or RFC 8058 one-click). |\n| GET    | `\u002Fmanage\u002F{token}`                          | Render the combined company-wide preferences page. |\n| POST   | `\u002Fmanage\u002F{token}`                          | Apply preference deltas across the company's lists. |\n| POST   | `\u002Fwebhooks\u002Fmailgun`                        | HMAC-verified Mailgun webhook: auto-purge on hard bounce \u002F spam complaint. |\n\n`{list}` and `{company}` accept either id or slug. Listing endpoints use opaque base64 cursors; default `limit` is 50, max 500.\n\n## Configuration\n\n| Env var                        | Required | Default                  | Purpose |\n|--------------------------------|----------|--------------------------|---------|\n| `MAILGUN_API_KEY`              | yes      | —                        | Mailgun API key (HTTP Basic password; user is `api`). |\n| `MAILGUN_REGION`               | no       | `us`                     | `us` or `eu`. |\n| `MAILGUN_API_BASE`             | no       | derived from region      | Explicit override for the API base URL. |\n| `MINIGUN_PUBLIC_URL`           | yes      | —                        | Public origin used to build per-recipient unsubscribe URLs. |\n| `MINIGUN_HMAC_SECRET`          | yes      | —                        | Secret used to HMAC-sign unsubscribe \u002F manage tokens. |\n| `MINIGUN_API_TOKEN`            | no       | —                        | Bearer token required on every API request when set. |\n| `MINIGUN_DB_PATH`              | no       | `\u002Fdata\u002Fminigun.db`       | SQLite file path (Go server only). |\n| `MINIGUN_LISTEN_ADDR`          | no       | `:8080`                  | HTTP listen address (Go server only). |\n| `MINIGUN_TURNSTILE_SITE_KEY`   | no       | —                        | Cloudflare Turnstile site key. |\n| `MINIGUN_TURNSTILE_SECRET_KEY` | no       | —                        | Turnstile secret. Required when site key is set. |\n| `MAILGUN_WEBHOOK_SIGNING_KEY`  | no       | —                        | Mailgun \"HTTP webhook signing key\" (Sending → Webhooks). When set, `\u002Fwebhooks\u002Fmailgun` accepts signed bounce\u002Fcomplaint events and auto-purges contacts. When unset, the endpoint refuses all requests. |\n| `EVENTS_ARCHIVE_ENABLED`       | no       | `false`                  | Activates the Mailgun events archive pull cron + the read surface (`\u002Fsend\u002F{id}\u002Frecipients`, `\u002Fsend\u002F{id}\u002Fclicks`, `\u002Fcontacts\u002F{id}\u002Fengagement`). Schema and send-path tagging ship dormant; flip to `true` whenever you're ready to start collecting. See [docs\u002Fevents-archive.md](.\u002Fdocs\u002Fevents-archive.md). |\n| `LIST_HYGIENE_AUTO_PRUNE_ENABLED` | no    | `false`                  | When `true`, the engagement-based prune executor runs once per day against every list. Manual `POST \u002Flists\u002F{list}\u002Fprune` works independently. See [docs\u002Flist-hygiene.md](.\u002Fdocs\u002Flist-hygiene.md). |\n| `LIST_HYGIENE_AUTO_PRUNE_BY_COUNT` | no   | `20`                     | Auto-prune contacts whose `messages_since_last_engagement >= N`. Set to `0` to disable this criterion in the cron. |\n| `LIST_HYGIENE_AUTO_PRUNE_BY_RECENCY_DAYS` | no | `180`              | Auto-prune contacts whose last open\u002Fclick is older than N days. Set to `0` to disable. |\n| `LIST_HYGIENE_AUTO_PRUNE_NO_DELIVERY_DAYS` | no | `0` (disabled)    | Auto-prune contacts subscribed before the cutoff with no delivered events in N days. Aggressive on new lists — defaults disabled. |\n\nPer-deployment specifics (D1 binding for the Worker, secrets vs vars, etc.) live in the install docs.\n\n## Development\n\n```bash\ncd src    && go build .\u002F...     && go test .\u002F... && go vet .\u002F...\ncd cli    && go build .\u002F...     && go test .\u002F...\ncd worker && npm install        && npx tsc --noEmit && npx wrangler dev\n```\n\n## Project layout\n\n```\n.\n├── README.md\n├── LICENSE\n├── docs\u002F                   # install + CLI + MCP + hygiene + archive walkthroughs\n│   ├── cloudflare.md\n│   ├── cli.md\n│   ├── binary.md\n│   ├── docker.md\n│   ├── list-hygiene.md\n│   └── events-archive.md\n├── Dockerfile              # multi-stage Alpine build for the Go server\n├── docker-compose.yml      # example service-style deployment\n├── src\u002F                    # the Go server (module: github.com\u002Franaroussi\u002Fminigun)\n│   ├── main.go\n│   ├── cmd\u002F\n│   └── internal\u002F\n│       ├── api\u002F            # chi handlers\n│       ├── db\u002F             # SQLite + embedded goose migrations\n│       ├── mailgun\u002F        # Mailgun client (messages + Metrics API)\n│       ├── render\u002F         # markdown → HTML\u002Ftext, variable rewriter\n│       ├── store\u002F          # SQLite repository layer\n│       ├── tmpl\u002F           # embedded unsubscribe.html \u002F manage.html\n│       ├── token\u002F          # HMAC unsubscribe tokens\n│       ├── turnstile\u002F      # Cloudflare Turnstile siteverify\n│       └── worker\u002F         # bulk send worker + scheduler + stats refresher\n├── cli\u002F                    # standalone CLI + MCP (module: github.com\u002Franaroussi\u002Fminigun\u002Fcli)\n│   ├── go.mod\n│   ├── cmd\u002F\n│   │   ├── minigun\u002Fmain.go # `go install` entry point — produces the `minigun` binary\n│   │   └── *.go            # cobra commands\n│   └── internal\u002F\n├── worker\u002F                 # Cloudflare Worker port (TypeScript + Hono + D1 + Web Crypto)\n│   ├── wrangler.toml\n│   ├── migrations\u002F         # D1 migrations mirroring the Go server's goose migrations\n│   └── src\u002F\n├── sdks\u002F                   # Single-file, zero-dep client SDKs (one per language)\n│   ├── php\u002Fminigun.php\n│   ├── python\u002Fminigun.py\n│   ├── typescript\u002Fminigun.ts\n│   └── go\u002F{go.mod,minigun.go}\n└── skill\u002F                  # Agent (and any MCP-aware agent) operator skill\n    └── minigun\u002FSKILL.md\n```\n\n## License\n\n[MIT](.\u002FLICENSE) © 2026 Ran Aroussi\n","MiniGun 是一个基于 Mailgun 和 Cloudflare 的轻量级自托管邮件发送服务。它允许用户通过 Markdown 编写邮件内容，并可通过命令行界面或任何支持 MCP 协议的 AI 客户端进行操作，同时可以部署到 Cloudflare 的边缘网络上运行，无需额外的基础设施如 Redis 或消息队列。其核心功能包括支持 Markdown 格式的邮件编写、崩溃恢复机制下的批量邮件发送、定时发送以及自动列表清理等，旨在简化电子邮件营销活动中的常见任务。适合需要灵活控制邮件发送流程且希望减少运维负担的小团队或个人开发者使用。","2026-06-11 04:06:40","CREATED_QUERY"]