How Listmonk Handles POP3 Bounce Processing: A Technical Deep Dive

Listmonk processes email bounces by connecting to a POP3 mailbox at configurable intervals, parsing raw MIME messages to extract SMTP diagnostic codes, and automatically updating subscriber status when bounce thresholds are exceeded.

Listmonk, the open-source newsletter and mailing list manager, implements a robust POP3 bounce processing pipeline that runs continuously in the background to maintain list hygiene. The system fetches raw bounce messages from a configured POP3 mailbox, extracts delivery failure information, and updates subscriber records accordingly. This article examines the complete technical implementation found in the knadh/listmonk repository, from mailbox configuration to automatic subscriber disabling.

Configuring POP3 Bounce Mailboxes

Bounce handling begins with credentials specified in config.toml. The bounce.pop3 section defines the mailbox connection parameters:

[bounce.pop3]
host = "pop.mailprovider.com"
port = 995
username = "bounces@example.com"
password = "********"
tls = true          # Use TLS

interval = "5m"     # Check every 5 minutes

The system uses the Go library github.com/knadh/pop3 to create a pop.Client instance. By default, Listmonk checks the mailbox every five minutes, though this interval is configurable via the interval setting.

The POP3 Retrieval Workflow

The bounce retrieval logic resides in internal/bounce/mailbox/pop.go. This component orchestrates the connection, message fetching, and cleanup operations.

Connecting and Authenticating

When the bounce worker starts, it initializes a POP3 client using the configured credentials:

import "github.com/knadh/listmonk/internal/bounce/mailbox/pop"

p, err := pop.New(pop.Config{
    Host:     cfg.Bounce.Pop3.Host,
    Port:     cfg.Bounce.Pop3.Port,
    Username: cfg.Bounce.Pop3.Username,
    Password: cfg.Bounce.Pop3.Password,
    TLS:      cfg.Bounce.Pop3.TLS,
})

Fetching and Deleting Messages

The worker repeatedly calls client.Retr() to retrieve each message and client.Dele() to remove it after processing. This ensures that processed bounces are not re-fetched on subsequent runs:

// Fetch and process every message
for {
    msgs, err := p.RetrieveAll()
    if err != nil { 
        log.Printf("pop fetch error: %v", err) 
    }
    for _, raw := range msgs {
        bounceInfo, err := bounce.Parse(raw)
        if err == nil {
            _ = bounce.Store(bounceInfo)
        }
        p.Delete(raw.ID) // Always delete after attempt
    }
    time.Sleep(cfg.Bounce.Pop3.Interval)
}

If network or authentication errors occur, the system logs the failure and retries on the next scheduled run. Messages that cannot be parsed are skipped but still deleted to prevent infinite reprocessing loops.

Parsing Bounce Data and Storage

Once retrieved, raw MIME messages pass through the generic bounce parser in internal/bounce/bounce.go. This parser extracts critical delivery failure metadata:

  • SMTP response codes (e.g., 550, 421)
  • Original recipient addresses
  • Diagnostic reason text (e.g., "mailbox full", "user unknown")

Parsed data persists to the bounces table, defined in models/bounces.go. Each bounce creates a unique record containing the message ID, timestamp, diagnostic code, and the associated subscriber ID (looked up via email address).

Subscriber Status Management and Thresholds

After storing bounce records, internal/core/bounces.go executes the subscriber update logic. The system increments the bounce_count column for the affected subscriber and evaluates it against the threshold defined in config.toml (bounce.threshold):

if sub.BounceCount >= cfg.Bounce.Threshold {
    sub.Status = models.StatusDisabled
    db.UpdateSubscriber(sub)
}

When the bounce count exceeds the configured threshold, Listmonk automatically disables the subscriber (soft-bounces them), preventing future campaign sends to that address. This protects sender reputation by maintaining list hygiene automatically.

Manual Processing and CLI Integration

While the manager runs POP3 bounce processing as a background goroutine alongside campaign sending and queue processing, administrators can manually trigger processing via command line:

listmonk bounce process

This CLI command, implemented in cmd/bounce.go, executes the same retrieval and processing pipeline outside the normal scheduling loop, useful for immediate troubleshooting or initial setup verification.

Integration with Metrics and Notifications

Bounce statistics surface through the /api/stats endpoint, providing visibility into delivery health. Additionally, the system supports webhook notifications for bounces originating from external providers (SendGrid, SES) through handlers in internal/bounce/webhooks/, though these operate independently from the POP3 retrieval flow.

Summary

  • Configuration: POP3 credentials reside in config.toml under [bounce.pop3], supporting TLS and configurable check intervals.
  • Retrieval: internal/bounce/mailbox/pop.go uses github.com/knadh/pop3 to fetch messages via client.Retr() and deletes them with client.Dele() after processing.
  • Parsing: internal/bounce/bounce.go extracts SMTP codes and diagnostic text from raw MIME messages.
  • Storage: Bounce records persist to the bounces table (models/bounces.go) with subscriber correlation.
  • Automation: internal/core/bounces.go increments bounce counts and disables subscribers exceeding the configured threshold.
  • Operations: Background processing runs via internal/manager/manager.go, with manual override available via listmonk bounce process.

Frequently Asked Questions

How do I configure Listmonk to process bounces via POP3?

Add a [bounce.pop3] section to your config.toml file specifying the host, port, username, password, TLS setting, and check interval. Listmonk will automatically create a POP3 client and begin checking the mailbox at the specified interval (default 5 minutes).

What happens to emails in the POP3 mailbox after processing?

Listmonk deletes each message from the POP3 server immediately after attempting to parse it, regardless of whether the parse succeeded. This prevents the same bounce from being processed multiple times and keeps the mailbox from filling up.

How does Listmonk determine when to disable a subscriber?

After recording a bounce in the bounces table, Listmonk increments the subscriber's bounce_count column. If this count reaches or exceeds the bounce.threshold value set in config.toml, the subscriber's status changes to disabled automatically, preventing future campaign deliveries to that address.

Can I process bounces manually without waiting for the scheduled interval?

Yes. Run the command listmonk bounce process from the CLI to immediately trigger the POP3 retrieval and processing workflow. This command, defined in cmd/bounce.go, executes the same logic as the background worker without waiting for the next scheduled tick.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →