Skip to main content
Glama
robconery

big-mailer

by robconery

big-mailer 📬

Рассылки, серии писем и транзакционные письма в одном Cloudflare Worker. Самостоятельно размещаемая замена платному ESP, где список, отправка и данные о вовлечённости остаются у вас.

CI License: MIT TypeScript Cloudflare Workers

Статус: функционально завершён и запускается локально, но ещё не развёрнут. Он создан для одного оператора и не является мультитенантным — это осознанное решение, а не упущение. См. Перед развёртыванием для честного списка того, что осталось.

Панель big-mailer, показывающая согласия, разбитые по областям

Панель после загрузки демо-данных. Согласие по областям — это панель, которая имеет значение: два человека вышли из отдельной серии и остались в списке. В обычном ESP эти два числа были бы одинаковыми.


Идея 💡

Каждый ESP рассматривает отписку как один переключатель. Кто-то заканчивает вашу приветственную серию, нажимает «отписаться», чтобы остановить её, и тихо навсегда покидает вашу рассылку. Вы никогда об этом не узнаете. Просто число уменьшается.

Здесь согласие ограничено областью. Выход из одной серии убирает вас из этой серии. Выход из рассылки не отменяет серию, на которую вы сознательно подписались. Только явное «отписаться от всего», жёсткий отказ или жалоба на спам полностью удаляют человека.

Именно эта асимметрия и является причиной существования проекта, и всё остальное в кодовой базе устроено так, чтобы её нельзя было случайно нарушить.


Related MCP server: Resend MCP Server

Запуск 🚀

bun install
bun run db:migrate     # applies migrations to the local D1 database
bun run dev            # http://localhost:8787

Откройте http://localhost:8787 и нажмите Загрузить демо-данные: двенадцать человек, две активные серии, отправленная рассылка. Затем откройте Исходящие, чтобы прочитать письма, которые «ушли».

Ничего не покидает ваш компьютер. EMAIL_PROVIDER=console — это локальное значение по умолчанию, которое записывает полностью отрендеренные письма во встроенные «Исходящие» вместо их отправки. Никаких API-ключей не нужно, и невозможно случайно отправить письмо реальному человеку, пока вы экспериментируете.

Попробуйте то, для чего это создано 🎯

  1. Подписчики → выберите кого-нибудь → Откройте его центр предпочтений

  2. Добавьте ?scope=sequence:2 к этому URL. Так выглядит ссылка внутри письма из серии.

  3. Нажмите Остановить только эту серию

  4. Вернитесь на страницу подписчика: он всё ещё active, всё ещё в рассылке, но вышел ровно из одной серии

  5. Согласие показывает всех, кто покинул одну серию, по сравнению с (пустым) списком полностью ушедших людей

После этого отправьте рассылку — они её всё ещё получат. В этом весь аргумент.

Задержки в сериях указываются в днях, и первый шаг по умолчанию равен 0 (приходит при подписке), а последующие шаги по умолчанию равны 1. Это означает, что созданная серия не завершится, пока вы за ней наблюдаете, поэтому на панели есть Перемотать время вперёд (только локально): он переносит все ожидающие шаги на текущий момент и выполняет тик. Воспользуйтесь этим, и вы увидите, как шаг 2 пропускает людей, покинувших эту серию.


Что здесь 🗂

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 log

Примерно 16 тысяч строк TypeScript. bun run typecheck проверяет Worker и браузерный бандл по отдельности и проходит без ошибок.


Архитектура в двух словах 🧱

Cloudflare Workers · D1 (SQLite) через Drizzle · Queues для фан-аута отправки · Cron Triggers для планирования · R2 для медиа · Hono + JSX серверный рендеринг админки · Cloudflare Access для аутентификации · подключаемый порт EmailProvider с адаптерами console и Resend.

Решения, о которых стоит знать и почему:

Каждая строка messages материализуется до того, как будет отправлено хоть одно письмо. Рассылка заранее формирует полный список получателей, записывает по строке на каждое предполагаемое письмо и только потом отправляет в очередь. Это делает рассылку возобновляемой после сбоя, идемпотентной при повторах в очереди и проверяемой впоследствии. Ленивое разрешение получателей во время отправки дешевле, но превращает любой сбой в середине рассылки в невосстановимый беспорядок.

Согласие проверяется непосредственно перед вызовом провайдера, а не при постановке в очередь. Очередь может доставить сообщение через несколько минут после его создания, и за это время кто-то может отписаться. Проверка при постановке в очередь привела бы к тому, что письмо всё равно бы ушло.

Параллельность очереди зафиксирована на 6. Один батч — это один запрос к провайдеру, поэтому параллельность батчей и есть частота запросов. Если оставить значение по умолчанию, Cloudflare Queues автоматически масштабируется до 250 одновременных потребителей, заваливает лимит Resend в 10 запросов/с ошибками 429, сжигает все три повтора и отправляет в мёртвую очередь вполне нормальные письма. Шесть батчей по 100 оставляют запас примерно в 600 писем/с, оставаясь в пределах лимита.

Подавление привязано к адресу электронной почты, а не к подписчику. У транзакционных получателей и отскочивших адресов часто вообще нет строки подписчика, поэтому флаг subscribers.status молча бы их пропустил.

Всё, что можно наблюдать, — это строка в D1, а не запись в логе. Логи Workers хранятся 3–7 дней. Аудит-трейл с недельным хранением — это не аудит. mcp_calls записывает каждое действие агента, включая отказы; sync_runs записывает каждое извлечение из Stripe.

В админ-консоли нет пароля. Cloudflare Access завершает идентификацию на периметре, а src/web/auth.ts правильно проверяет пересланный JWT: подпись по живому JWKS команды (кэшируется на изолят, с принудительным повторным запросом при неизвестном key id), alg зафиксирован как RS256, плюс аудитория, издатель, exp и nbf. Наличие заголовка ничего не доказывает и никогда не считается доказательством. При неправильной настройке промежуточное ПО отказывает в закрытом режиме и блокирует всех, включая вас. Это правильное направление отказа.

HTML-рендерер писем написан вручную (core/render-doc.ts) вместо использования @tiptap/html, чья серверная точка входа требует happy-dom и не работает внутри workerd. В итоге это оказалось лучшим решением: обходчик инлайнит все стили (Gmail удаляет <style>) и генерирует вложенные таблицы для кнопок (Outlook игнорирует padding у <a>), чего не сделала бы обычная HTML-сериализация.

Полный журнал решений, включая отклонённые альтернативы и причины, — в docs/MEMORY.md.


Модель согласия 🔐

Три независимые области. Узкий выбор никогда не перерастает в широкий.

Область

Где хранится

Эффект

Серия

строка sequence_optouts

Выход из этой серии. Всё остальное продолжается.

Рассылка

subscribers.status

Выход из рассылки. Серии продолжают идти.

Глобально

строка suppressions

Выход из всего. Юридический запасной выход.

Только явное «отписаться от всего», жёсткий отказ или жалоба создают глобальную подавление.

Отправка серий намеренно игнорирует status = 'unsubscribed', потому что этот флаг относится к рассылке: человек, покинувший рассылку, всё равно получит приветственную серию, на которую подписался. Транзакционные письма (чеки, загрузки) полностью игнорируют маркетинговое согласие и блокируются только мёртвым адресом или жалобой на спам. Чек — это не маркетинг, и отписавшемуся клиенту всё равно нужна его загрузка.


Редактор ✍️

Блочный форматированный текст, TipTap v3, ванильный (без React). Откройте черновик «Черновик: всё, что умеет редактор», чтобы увидеть всё.

  • / в строке → меню блоков: заголовки, списки, чек-листы, цитаты, код, таблицы, переключатели, разделители, изображения, YouTube, кнопка CTA

  • @ → поля персонализации как настоящие узлы, так что first_name невозможно написать с ошибкой

  • Перетащите маркер на левом поле, чтобы изменить порядок; shift-клик выделяет несколько блоков

  • Перетащите или вставьте изображение в любом месте → загружается в R2, вставляется, когда URL будет готов

  • Блоки кода подсвечиваются синтаксисом (15 языков, включая Ruby, Elixir, TS, SQL)

  • Выделите текст для всплывающего меню; выберите кнопку — и всплывающее меню станет редактором её URL и цвета

Кнопки и merge-теги — это пользовательские узлы, созданные специально для электронной почты. CTA отображается как вложенная таблица, и все стили инлайновые. Merge-тег — это узел, а не сырой текст {{first_name}}, потому что опечатка в тексте отправит «Привет, {{frist_name}}» всему списку.

Тела хранятся как TipTap JSON в body_json. Устаревшая разметка в body_md по-прежнему рендерится и конвертируется в момент открытия в редакторе. Ничего не мигрирует массово, потому что массовая миграция, пошедшая не так, утащит за собой и архив.

Клиентский бандл — ~226 КБ в gzip и загружается только на двух экранах, где составляются письма. Всё остальное в админ-консоли рендерится на сервере без единой строчки JavaScript.

Есть браузерный смоук-тест (bun run smoke, 33 проверки), управляющий настоящим Chromium, потому что переименованный параметр расширения молча не работает в браузере, и поле тела просто не сохраняется. Ничто на сервере не может это поймать.


Управление из Claude Code 🤖

Worker предоставляет MCP-сервер на POST /mcp/<secret>: 93 инструмента, покрывающих весь почтовик, так что агент может нарезать сегменты, составлять и отправлять рассылки, строить серии, читать эффективность кампаний и сверять 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"

Три уровня защиты, от дешёвого к дорогому. Секрет в пути, который невозможно угадать, сравнивается за константное время (промах возвращает 404, а не 403, потому что URL, который никто не угадал, должен выглядеть так, будто его не существует), затем bearer-ключ с правами admin (транзакционные send-ключи до него не допускаются), а затем поштучные проверки инструментов. Каждый вызов попадает в mcp_calls, включая отказы.

Необратимые отправки требуют предварительной проверки. broadcast_send отказывается работать без токена от broadcast_preflight: одноразовый, срок действия 10 минут, аннулируется при любом изменении контента или аудитории. То же самое для sequence_activate. Кроме того, MCP_ALLOW_SEND имеет значение "false" в продакшене, поэтому MCP может читать и составлять что угодно, но не может отправить письмо, пока вы намеренно не переключите его. Обратное переключение — это мгновенный аварийный выключатель.

Агенты должны прочитать bigmailer://conventions, прежде чем касаться согласия. Ограниченная отписка — это не та форма, которую ожидает кто-то, обученный на обычных ESP, и ошибиться здесь — ровно тот сбой, которого этот проект и призван избежать.


Stripe → атрибуция кампаний 💳

Установите STRIPE_SECRET_KEY (ограниченный, только чтение по платежам/возвратам/ клиентам). Ежедневный cron в 09:17 UTC подтягивает новые платежи и относит каждый к последнему касанию атрибуции покупателя или к metadata.campaign, если платёж его содержит. Идемпотентность по идентификатору платежа Stripe, поэтому повторные запуски и перекрывающиеся бэкфиллы безвредны.

stripe_sync_preview выполняет пробный прогон, sales_unattributed — это список задач, которые эвристика не смогла разместить, а sync_runs_list доказывает, что ночная задача действительно выполняется.


Команды ▶️

bun run dev

Собрать клиентский бандл, затем запустить сервер на :8787

bun run watch:client

Пересобрать бандл редактора при изменении (вместе с dev)

bun run smoke

Браузерный смоук-тест редактора. Требует запущенного dev

bun run db:migrate

Применить миграции к локальной D1

bun run db:generate

Сгенерировать миграцию после редактирования src/db/schema.ts

bun run db:studio

Drizzle Studio для локальной базы данных

bun run typecheck

Проверка типов Worker и браузерного бандла по отдельности

bun run deploy

Сборка, затем wrangler deploy --env production. Сначала прочтите раздел ниже


Настоящая отправка 📮

Скопируйте .dev.vars.example в .dev.vars, добавьте ключ Resend и установите EMAIL_PROVIDER=resend. Укажите вебхук Resend на /webhooks/resend, чтобы отказы и жалобы корректно подавлялись. Без этого вебхука плохие адреса никогда не будут подавляться, и ваша репутация отправителя будет тихо деградировать — это медленный путь к потере доставляемости для всего домена.

Секреты хранятся в .dev.vars (в gitignore) или через wrangler secret put. Никогда в wrangler.jsonc и никогда в .dev.vars.example.

⚠️ Перед развёртыванием

Ещё не развёрнуто, и между этим состоянием и продакшеном есть реальная настройка:

  • DEV_AUTH_BYPASS=true находится в переменных верхнего уровня wrangler.jsonc, чтобы приложение можно было запускать локально. --env production устанавливает его в false. Голый wrangler deploy публикует неаутентифицированную админ-консоль, именно поэтому bun run deploy жёстко прописывает --env production. Не обходите это.

  • database_id — это заглушка. Создайте настоящую базу D1 с помощью wrangler d1 create.

  • Создайте бакет R2 (big-mailer-media) и две очереди (big-mailer-send, big-mailer-dlq). Для очередей нужен платный план Workers за $5/мес.

  • Установите PUBLIC_URL на реальный хост. Он встраивается в трекинговые ссылки и URL изображений в момент отправки, поэтому неверное значение навсегда отправляет сломанные письма уже доставленной почте. Это нельзя исправить постфактум.

  • MCP_PATH_SECRET должен быть настоящим секретом (wrangler secret put), а не переменной. Если он не задан, эндпоинт MCP возвращает 404 — это безопасное поведение по умолчанию. Включайте его осознанно.

  • Cloudflare Access требует одно приложение Allow и несколько приложений Bypass. Защита всего хоста одной политикой Allow также защищает трекинговый пиксель, формы регистрации, центр предпочтений, вебхуки и MCP, а это значит, что каждый трекинговый пиксель в каждом отправленном письме будет навсегда перенаправляться на экран входа — для уже доставленной почты. Access сопоставляет наиболее конкретный путь первым, поэтому /t, /f, /p, /api, /webhooks и /mcp — каждому нужен свой Bypass-апп. Каждый из них либо несёт собственную аутентификацию, либо публичен по замыслу.

  • Нет серверного набора тестов. bun run smoke покрывает редактор. docs/SPEC.md написан как нумерованные, тестируемые требования, сформированные так, чтобы их можно было исполнять.


Документация 📚

docs/PROJECT.md

Проблема, для кого это, и что явно вне рамок

docs/ARCHITECTURE.md

Системный дизайн, модель данных и конвейер отправки

docs/SPEC.md

Нумерованные поведенческие требования. Эталон ожидаемого поведения

docs/MEMORY.md

Журнал решений: что выбрано, что отклонено и почему


Участие 🤝

Баг-репорты, исправления корректности и исправления рендеринга в почтовых клиентах очень приветствуются. Мультитенантность, конструктор с перетаскиванием и собственный SMTP намеренно вне рамок. См. CONTRIBUTING.md перед открытием PR и SECURITY.md, если вы нашли уязвимость (пожалуйста, сообщите о ней приватно, а не как issue).

Форкинг действительно поощряется. Этот проект достаточно мал, чтобы прочитать его целиком и сделать своим. Участие регулируется Кодексом поведения.

Лицензия 📄

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.

  • Markdown-first MCP server for Notion API with 8 composite tools and 39 actions.

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/big-mailer'

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