Skip to content

Listmonk with Amazon SES: Three Ways It Fails Quietly

Published: at 04:00 PM

I moved the newsletter for my apps to a self-hosted Listmonk (v6.2.0) that sends through Amazon SES. The happy path worked on the first try: signup, double opt-in, campaign, bounce. The three problems below only showed up in edge-case tests and a code review. None of them produces an error you would notice. All three are still there in v6.2.0, so if you run this combination, check your setup.

1. Complaints disappear with configuration-set events

SES can report bounces and complaints in two ways:

Listmonk’s SES webhook accepts both formats, but not both event types in both formats (internal/bounce/webhooks/ses.go):

if (m.EventType != "" && m.EventType != "Bounce") ||
	(m.NotifType != "" && (m.NotifType != "Bounce" && m.NotifType != "Complaint")) {
	return bounce, errors.New("notification type is not bounce")
}

With configuration-set events, a complaint arrives as eventType: "Complaint" and is dropped. The only trace is a log line. Bounces work, so a quick test with the SES bounce simulator passes. Only the complaint simulator shows it.

That matters more than it looks: a complaint is someone pressing “this is spam”. If Listmonk does not blocklist them, you mail them again, and SES reviews accounts with a complaint rate above 0.1%.

Fix: use identity notifications for both types. In Pulumi:

for kind in ["Bounce", "Complaint"]:
    aws.ses.IdentityNotificationTopic(
        f"tawadi-{kind.lower()}-notifications",
        identity=identity.email_identity,
        notification_type=kind,
        topic_arn=events_topic.arn,
        include_original_headers=False,
    )

Remove the configuration-set event destination afterwards, or bounces arrive twice. Then send a test campaign to complaint@simulator.amazonses.com and check that the address ends up blocklisted.

2. Throttled recipients are skipped, not retried

When SES throttles (TooManyRequestsException), the correct SMTP answer is a temporary error such as 451. Listmonk sends through smtppool, and its retry only covers network errors (pool.go, v2.1.2):

func canRetry(err error) bool {
	var netErr net.Error
	if errors.As(err, &netErr) {
		return true
	} else if _, ok := err.(*net.OpError); ok {
		return true
	} else if err == io.EOF {
		return true
	}
	return false
}

A 451 is none of these. The campaign manager logs the error, counts it and moves on to the next subscriber. The campaign still ends as “finished”. It only pauses after app.max_send_errors, which defaults to 1000. max_msg_retries in the SMTP settings does not help here.

So under throttling, some subscribers silently never get the mail. With a small list and message_rate well under your SES send rate, you will rarely hit it. A launch mail to a bigger list is when you do.

Fix: retry where the throttling happens. I send through ses-relay, a small open-source sidecar (SMTP in, SES v2 API out) that retries throttled calls itself, up to five attempts with 1, 2, 4 and 8 seconds between them, before it answers 451. It also gets its AWS credentials from the pod’s service-account token, so there is no SMTP password or access key to store. Without a relay, keep message_rate clearly below your SES limit and set app.max_send_errors low, so a burst of errors pauses the campaign where you can see it.

3. The captcha does not cover the JSON signup API

With “public subscription” enabled, Listmonk serves two signup endpoints:

Both send the double opt-in mail. If the second endpoint is reachable, a script can make your server send confirmation mails to any address, through your SES account and your domain’s reputation. The captcha you enabled does not change that.

This is known and intended: the maintainer said so in 2024, and the configuration docs list the public endpoints to expose. The JSON API is not on that list. It is just easy to miss if you route /api/public/* because the captcha challenge lives there. I did exactly that.

Fix: expose exactly the documented paths and nothing more. Mine:

/subscription/*                 signup form, opt-in, unsubscribe
/api/public/captcha/altcha      captcha challenge only
/webhooks/service/ses           SES bounce notifications
/link/*  /campaign/*  /archive/*  /public/*  /uploads/*

/admin and every other /api path are not public at all; I reach the admin UI over my tailnet. A quick check:

curl -s -o /dev/null -w '%{http_code}\n' \
  -X POST https://lists.example.com/api/public/subscription
# 404

Checklist

Each of these looked fine in a normal test. They only show up when you test the unhappy paths on purpose.


Previous Post
AWS Credentials for k3s Pods, Without a Single Stored Key
Next Post
Two Ahrefs Findings on My Astro Sites, Fixed