Notifuse: Self-Hosted Email Platform with Automations and A/B Testing

October 24, 2025

|repo-review

by Florian Narr

Notifuse: Self-Hosted Email Platform with Automations and A/B Testing

What it does

Notifuse is a self-hosted email platform built in Go and React that covers newsletters, transactional email via REST API, and visual automation workflows. It connects to Amazon SES, Mailgun, Postmark, Mailjet, SparkPost, or plain SMTP, and stores everything in PostgreSQL.

Why I starred it

Self-hosted email is a crowded space. Listmonk handles newsletters well but its automation story is thin. Mautic does everything but ships as a PHP monolith. Notifuse sits between those two — it has a proper campaign + automation engine without the bloat, and the backend is idiomatic Go with clean layering and a solid test suite.

What actually pushed me to star it was finding the broadcast orchestrator and circuit breaker sitting inside internal/service/broadcast/. Most projects at this stage just fire emails in a loop and call it done. Notifuse modeled the entire delivery pipeline as a resumable task with cursor-based pagination, retry logic, and provider failure isolation. That's the kind of engineering that shows up when someone has been burned by email delivery at scale.

How it works

The architecture follows classic clean layering: internal/domain/ owns structs and interfaces, internal/service/ owns business logic, internal/repository/ handles PostgreSQL via the Squirrel query builder, and internal/http/ wires everything to HTTP handlers. No magic — each layer only imports from the one below it.

The broadcast pipeline is the most interesting part. When you trigger a campaign send, the system creates a Task and hands it to BroadcastOrchestrator in internal/service/broadcast/orchestrator.go. The Process method runs cursor-based pagination over recipients in batches, tracking progress in a SendBroadcastState struct so a crashed or timed-out task can resume from exactly where it left off:

// FetchBatch retrieves a batch of recipients using cursor-based pagination
// afterEmail is the last email from the previous batch (empty for first batch)
FetchBatch(ctx context.Context, workspaceID, broadcastID, afterEmail string, limit int) ([]*domain.ContactWithList, error)

The defer block at the top of Process handles failure classification — if the task errors on its last retry, it checks whether the failure was due to a circuit breaker trip before marking the broadcast as failed. A circuit-breaker-induced pause keeps the broadcast in paused state so you can retry after fixing the provider, rather than losing the send entirely.

A/B testing is first-class. BroadcastTestSettings in internal/domain/broadcast.go carries a Variations slice, each pointing to a different template. The ABTestEvaluator in internal/service/broadcast/ab_test_evaluator.go reads open/click rates from message history and picks the winner automatically when AutoSendWinner is enabled. The broadcast state machine has dedicated statuses for this flow: testingtest_completedwinner_selectedprocessed.

The automation engine in internal/service/automation_executor.go is a node-based workflow runner. A contact enters the graph at a trigger node and advances through nodes until it hits a delay or reaches the end. Up to 10 nodes execute per tick:

// LOOP: Process nodes until delay, completion, or max iterations
const maxNodesPerTick = 10
for iterations := 0; iterations < maxNodesPerTick; iterations++ {

The supported node types cover most real-world needs:

const (
    NodeTypeTrigger          NodeType = "trigger"
    NodeTypeDelay            NodeType = "delay"
    NodeTypeEmail            NodeType = "email"
    NodeTypeBranch           NodeType = "branch"
    NodeTypeFilter           NodeType = "filter"
    NodeTypeAddToList        NodeType = "add_to_list"
    NodeTypeRemoveFromList   NodeType = "remove_from_list"
    NodeTypeABTest           NodeType = "ab_test"
    NodeTypeWebhook          NodeType = "webhook"
    NodeTypeListStatusBranch NodeType = "list_status_branch"
)

Trigger events are typed strings in internal/domain/automation.gocontact.created, list.subscribed, email.clicked, custom_event, and about 15 others. Custom events let you fire automations from your own app without needing to model everything in advance.

The test coverage is real. Nearly every service file has a corresponding _test.go with mockgen-generated mocks for all interfaces. The broadcast orchestrator alone has dedicated test files for cancellation, pause, and process paths — a level of coverage you rarely see in an open-source emailing tool.

Using it

The docs point to docs.notifuse.com for installation. The setup wizard handles initial config. Once running, the transactional API is a straightforward POST:

curl -X POST https://your-instance.com/api/workspaces/YOUR_WS/messages/send \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_email": "user@example.com",
    "template_id": "welcome-email",
    "variables": { "first_name": "Alice" }
  }'

Templates use Liquid syntax ({{ contact.first_name }}), which is standard enough that you can migrate content from Mailchimp or Brevo without rewriting variables.

Rough edges

The README is thin on operational detail — no guidance on scaling the worker process, no mention of how the task queue handles concurrent orchestrators, no Docker Compose example. The docs site fills some of this in, but discovery is poor if you're evaluating from GitHub alone.

The license is AGPL-3.0, and the contribution terms require transferring IP to the repo owner. That's a notable flag for teams considering forking or extending it commercially.

There's no mention of a built-in unsubscribe header injection or RFC 8058 one-click unsubscribe support. At volume, that omission creates deliverability risk with Gmail and Yahoo.

Bottom line

If you need a self-hosted email platform with actual automation logic — not just a list sender — Notifuse is worth deploying and evaluating. The Go backend is solid, the test coverage is honest, and the broadcast pipeline has the kind of resilience that usually only shows up after someone's production send has gone sideways.

Notifuse/notifuse on GitHub
Notifuse/notifuse