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:
- Configuration-set event destinations, which AWS documents as the
current way. Messages carry an
eventTypefield. - Identity feedback notifications, the older way. Messages carry a
notificationTypefield.
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:
POST /subscription/form: the HTML form. Checks the captcha (hCaptcha or Altcha) and the honeypot field.POST /api/public/subscription: JSON. Checks neither.
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
- Bounces and complaints via identity notifications; test with both SES simulator addresses.
- Retries for throttling in front of SES, or a send rate far below the
limit plus a low
max_send_errors. - Only the documented public endpoints routed;
POST /api/public/subscriptionreturns 404.
Each of these looked fine in a normal test. They only show up when you test the unhappy paths on purpose.