Internal Structure of Listmonk's Bounce Manager: Architecture and Data Flow
Listmonk's bounce manager uses a modular, queue-based architecture to ingest delivery failure events from mailboxes (via POP) or webhook providers (SES, SendGrid, Postmark, etc.), processing them through a buffered channel and persisting to the database via a configurable callback.
The bounce manager in the knadh/listmonk repository orchestrates the collection and storage of email bounce events. Designed with a pluggable pipeline, it separates ingestion sources from persistence logic, allowing operators to mix mailbox scanning and webhook endpoints without modifying core processing code.
Core Architectural Components
The Manager Struct
The central orchestrator is the Manager struct defined in internal/bounce/bounce.go (lines 46–58). It holds:
- A bounded channel (
queue chan models.Bounce) that buffers bounce events before processing - A concrete mailbox client implementing the
Mailboxinterface - Pointers to webhook clients for each enabled provider
- The configuration (
Opt) - Database query helpers (
Queries) - A logger instance
This struct coordinates the flow between external data sources and the database.
Configuration Options
The Opt struct (lines 20–44) controls which ingestion methods are active:
MailboxEnabledandMailboxTypetoggle POP/IMAP scanningWebhooksEnabledactivates HTTP endpointsRecordBounceCBis a critical function field that receives amodels.Bounceand returnserror, acting as the bridge to persistence logic
The Mailbox Interface
Bounce sources implement the Mailbox interface (lines 14–18):
type Mailbox interface {
Scan(limit int, ch chan models.Bounce) error
}
Currently, only POP is implemented in internal/bounce/mailbox/pop.go. The Scan method reads messages, parses them into models.Bounce structs, and pushes them onto the supplied channel.
Data Ingestion Sources
POP/IMAP Mailbox Scanning
When MailboxEnabled is true, the manager spawns a background goroutine running runMailboxScanner (lines 134–144). This loop:
- Calls
m.mailbox.Scan(1000, m.queue)to fetch up to 1000 messages - Pushes parsed bounces onto the manager's internal queue
- Sleeps for the configured
ScanIntervalbefore repeating
Errors during scanning are logged, but the loop continues indefinitely.
Webhook Providers
Provider-specific logic resides in internal/bounce/webhooks/. Each implements an HTTP handler that converts proprietary JSON payloads into models.Bounce objects:
- SES:
webhooks.NewSES()parses Amazon SNS notifications - SendGrid:
webhooks.NewSendgrid(key)validates secrets and unmarshals JSON - Postmark:
webhooks.NewPostmark(username, password)handles basic authentication - ForwardEmail:
webhooks.NewForwardemail(key)verifies signatures - Lettermint:
webhooks.NewLettermint(key)validates provider-specific tokens
These handlers write directly to the manager's queue channel, unifying webhook and mailbox data streams.
Initialization and Runtime Flow
The Constructor
The New function (lines 66–113) wires the system:
- Creates a buffered channel for the bounce queue
- Instantiates
pop.NewPOPif mailbox scanning is enabled - Initializes webhook clients only for configured providers
- Returns the fully wired
Manageror an error for unknown mailbox types
The Main Processing Loop
The Run method (lines 115–132) starts the system:
func (m *Manager) Run() {
if m.opt.MailboxEnabled {
go m.runMailboxScanner()
}
for b := range m.queue {
if b.CreatedAt.IsZero() {
b.CreatedAt = time.Now()
}
_ = m.opt.RecordBounceCB(b)
}
}
If mailbox scanning is enabled, it launches a goroutine. The main goroutine then blocks on m.queue, applying default timestamps and invoking RecordBounceCB for each bounce event. This design ensures that slow database writes do not block fast webhook deliveries or mailbox scanning.
Database Persistence
The actual SQL insertion is decoupled from the bounce manager. The RecordBounceCB callback is typically implemented in internal/core/bounces.go, which uses the prepared statement queries.RecordQuery to insert into the bounces table.
The Queries struct (lines 60–64) wraps a *sqlx.DB connection:
type Queries struct {
*sqlx.DB
RecordQuery *sqlx.Stmt
}
This separation allows the bounce manager to be tested without a database, and lets persistence logic evolve independently.
Practical Implementation Examples
Creating a Manager with POP Mailbox
import (
"log"
"os"
"time"
"github.com/jmoiron/sqlx"
"github.com/knadh/listmonk/internal/bounce"
"github.com/knadh/listmonk/models"
)
// Prepare database connection and statement
queries := &bounce.Queries{
DB: db,
RecordQuery: db.MustPreparex(`INSERT INTO bounces
(subscriber_id, email, reason, created_at)
VALUES (?, ?, ?, ?)`),
}
// Define persistence callback
recordCB := func(b models.Bounce) error {
_, err := queries.RecordQuery.Exec(
b.SubscriberID, b.Email, b.Reason, b.CreatedAt,
)
return err
}
// Configure options
opt := bounce.Opt{
MailboxEnabled: true,
MailboxType: "pop",
Mailbox: mailbox.Opt{
Host: "mail.example.com",
Username: "bounceuser",
Password: "secret",
ScanInterval: time.Minute * 5,
},
RecordBounceCB: recordCB,
}
// Initialize and start
mgr, err := bounce.New(opt, queries, log.New(os.Stderr, "bounce: ", log.LstdFlags))
if err != nil {
log.Fatal(err)
}
go mgr.Run()
Recording a Bounce Manually
For testing or manual insertion, push directly to the queue:
b := models.Bounce{
SubscriberID: 42,
Email: "bounced@example.com",
Reason: "hard-bounce",
CreatedAt: time.Now(),
}
_ = mgr.Record(b) // Enqueues for background processing
Registering a SendGrid Webhook Handler
http.HandleFunc("/webhooks/sendgrid", func(w http.ResponseWriter, r *http.Request) {
if err := mgr.Sendgrid.Handle(r, mgr.queue); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
})
Summary
- The Manager struct in
internal/bounce/bounce.goorchestrates ingestion via a buffered channel that decouples receiving from persistence. - Mailbox implementations (currently POP) poll mailboxes continuously in a background goroutine, pushing parsed bounces to the queue.
- Webhook clients for SES, SendGrid, Postmark, ForwardEmail, and Lettermint translate provider-specific payloads into standard
models.Bouncestructs. - The
RecordBounceCBcallback decouples the manager from database logic, allowing the core bounce processing to remain provider-agnostic. - Initialization happens through
bounce.New(), which wires enabled sources based on theOptconfiguration struct.
Frequently Asked Questions
How does the bounce manager handle concurrent inputs from webhooks and mailboxes?
The manager uses a single buffered channel (m.queue) as the concurrency control point. Both the mailbox scanner goroutine and multiple webhook HTTP handlers write to this channel concurrently. The main Run() loop reads from the channel sequentially, ensuring that database writes happen in a controlled manner while ingress operations remain non-blocking up to the channel buffer size.
Can I extend the manager to support IMAP mailboxes?
Yes. Create a new file in internal/bounce/mailbox/ implementing the Mailbox interface with a Scan(limit int, ch chan models.Bounce) error method. Update bounce.New() to instantiate your IMAP client when opt.MailboxType == "imap". The interface abstracts all connection details, so the manager requires no other changes.
Where is the bounce data actually stored?
The manager delegates persistence to the RecordBounceCB callback defined in Opt. By default, this callback is implemented in internal/core/bounces.go, which executes a prepared SQL statement against the bounces table using the Queries.RecordQuery statement. This design keeps storage logic separate from the ingestion pipeline.
What happens if the database is slow or temporarily unavailable?
The buffered m.queue channel provides backpressure. If the database slows down, the channel fills up. Once the buffer is full, webhook handlers and the mailbox scanner will block on send operations until the Run() loop drains the queue. This prevents memory exhaustion during database slowdowns while maintaining data integrity.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →