Bulk Email Dispatch with Go: A Producer-Consumer Approach
How I built MailFlow — a Go-based bulk email system using producer-consumer pattern with CSV processing and templating.

Why I Built MailFlow
Sending bulk emails sounds simple until you actually try to build it. Most tutorials show you a basic SMTP loop — open connection, send, close. But what happens when you have 10,000 recipients and you need to send them all without blowing up your memory or getting rate-limited into oblivion?
That's the problem MailFlow solves. It's a lightweight email dispatch system written in Go, designed around the producer-consumer pattern for concurrent processing.
Architecture Overview
The system is split into three clean components:
- CSV Parser — reads recipient data from a CSV file
- Producer — feeds jobs into a buffered channel
- Consumer Pool — goroutines that pull from the channel and send emails
type EmailJob struct {
To string
Subject string
Body string
}
func Producer(jobs chan<- EmailJob, recipients []Recipient, tmpl *template.Template) {
for _, r := range recipients {
var buf bytes.Buffer
tmpl.Execute(&buf, r)
jobs <- EmailJob{
To: r.Email,
Subject: "Your Subject",
Body: buf.String(),
}
}
close(jobs)
}The Producer-Consumer Pattern in Go
Go makes this pattern trivially easy with channels. The producer pushes EmailJob structs into a buffered channel, and N consumer goroutines pull from it:
func Consumers(jobs <-chan EmailJob, wg *sync.WaitGroup) {
for job := range jobs {
sendEmail(job)
wg.Done()
}
}
func main() {
jobs := make(chan EmailJob, 100)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go Consumers(jobs, &wg)
}
Producer(jobs, recipients, tmpl)
wg.Wait()
}The buffered channel acts as a work queue — producers don't block until the buffer is full, and consumers process work as fast as they can. This gives you natural backpressure without any external message broker.
CSV Processing
The CSV parser is straightforward. Each row maps to a Recipient struct:
type Recipient struct {
Name string
Email string
Role string
}Using Go's encoding/csv package, we read the file row by row and build the recipient list. The templating engine then fills in placeholders like {{.Name}} in the email body.
Template Rendering
Email bodies are Go templates. This means you get full logic support — conditionals, loops, functions — all rendered server-side before dispatch:
Hello {{.Name}},
{{if eq .Role "admin"}}
As an admin, you have full access to the dashboard.
{{else}}
Welcome to the platform! Here's your getting started guide.
{{end}}What I Learned
- Go channels are incredible for producer-consumer patterns. No need for Redis, RabbitMQ, or any external queue for lightweight workloads.
- Buffered channels provide natural backpressure — if consumers are slow, the producer blocks, preventing memory blowup.
- Template rendering is fast — Go's template engine handles thousands of renders per second without breaking a sweat.
- Graceful shutdown matters — in production, you'd want to drain the channel before exiting.
Next Steps
The next iteration will add:
- SMTP connection pooling for higher throughput
- Retry logic with exponential backoff
- Rate limiting per recipient domain
- A simple HTTP API for triggering sends
The full source code is available on GitHub.