-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Description
I'm still getting a ton of the 421 Connection closed by server. Maximum message count per session reached. errors. Discussed in this thread: #1717
Some possible solutions here: https://repost.aws/questions/QUw5qK9rBFSKqXvW5a-nAteg/amazon-ses-smtp-error-response-421-connection-closed-by-server-maximum-message-count-per-session-reached
I asked Chat GPT, and it said:
Amazon SES has a Per-connection cap of 1,000 msgs per SMTP connection, after which SES closes it with the 421. So the SMTP client must reconnect periodically — you can’t keep one connection running forever..
I was unable to verify this with a web search, but it seems to align with the error message.
I asked GPT if I could simply set listmonk's "SMTP max connections" to something high like 80 if I'm sending 80k emails. It said:
That’s not necessary — and it would actually hurt performance (too many connections thrashing). Instead, you just need listmonk to **recycle connections periodically**. Even with 5–10 connections, if each one handles batches of 500–1000 messages, your 200k campaign will go through fine without hitting SES errors.
EDIT: I tried 80 SMTP conns and it didn't help or cause any new issues.
GPT then told me this:
### 2. What listmonk doesI checked the listmonk code: it uses go-mail for SMTP. That library does recycle SMTP connections — it closes and reopens after hitting a threshold. The threshold is configurable in listmonk via
max messages per connection(internallyMaxMsgPerConn).👉 That means you do not need to hack the code. The knob already exists.
I think it hallucinated that though, because it doesn't exist in the listmonk code: https://github.com/search?q=repo%3Aknadh%2Flistmonk%20MaxMsgPerConn&type=code
So perhaps adding a setting like that would be a good solution?
I confronted GPT with the hallucination, and after it thought & searched for 3.5 minutes, it said:
GPT's response
Short answer up front — yes: listmonk (via the smtppool library it uses) does not currently expose a “max messages per SMTP connection” option, so simply increasing max connections in listmonk will only help if total_messages / max_connections stays below whatever per-connection limit SES enforces. SES (and several people in the community) have been returning the 421 Connection closed by server. Maximum message count per session reached error recently — i.e. SES is closing sessions after N messages. ([Go Packages][1])
Below I explain the implications, give immediate (no-code) workarounds you can try, and then provide a concrete code plan + example patches you can apply (two places to change: smtppool and the listmonk code that constructs the pool) so listmonk will automatically rotate connections before SES closes them.
1) What I checked / confirmed
smtppool.Opt(the options struct used by listmonk’s SMTP pool) exposesMaxConns,MaxMessageRetries,IdleTimeout,PoolWaitTimeoutand TLS options — there is noMaxMessagesPerConn/MaxMsgPerConnfield today. (So the pool will happily reuse a connection for arbitrarily many messages until it’s closed for other reasons.) ([Go Packages][1])- listmonk constructs
smtppool.Optin its messenger/email code and passes settings into the pool. That is the code you’d change to expose a per-connection cap to the pool. ([GitHub][2]) - The specific SES 421 error you pasted is a known/observed symptom when SES closes an SMTP session after a provider-side limit on messages per session. Community threads show others hitting this recently. ([Repost][3])
3) Longer-term / recommended fix — code changes (what to change)
Summary of change: add a MaxMessagesPerConn option to smtppool and have listmonk pass a setting to it. The pool must track how many messages each connection has sent and close a connection when it reaches that limit (and also handle the 421 "maximum message count" response gracefully by marking that connection dead and retrying on a fresh connection).
Below I give specific, copy-pasteable guidance / snippets you can apply.
A — smtppool changes (recommended library change)
File: smtppool — add an option and per-connection counter, and close connections after N messages.
- Add new Opt field
// in pool.go (or the file where Opt is defined)
type Opt struct {
Host string
Port int
HelloHostname string
MaxConns int
MaxMessageRetries int
IdleTimeout time.Duration
PoolWaitTimeout time.Duration
SSL bool
Auth smtp.Auth
TLSConfig *tls.Config
// New: maximum messages to send on a single SMTP connection before closing it.
// 0 or negative => unlimited.
MaxMessagesPerConn int `json:"max_msgs_per_conn"`
}- Track per-connection sends and rotate when limit reached
Inside the connection wrapper (there will be a struct that represents a pooled connection — adapt the name to match smtppool's code, e.g. type client struct { ... }), add a counter:
type clientConn struct {
/* existing fields */
// msgsSent counts successful messages sent on this connection
msgsSent int
}- After a successful Send on a connection, increment and close if needed
Find the code path that sends an email on a picked connection (e.g. func (p *Pool) Send(e Email) error { ... }). After a successful send, do:
c.msgsSent++
if p.opt.MaxMessagesPerConn > 0 && c.msgsSent >= p.opt.MaxMessagesPerConn {
// close this connection and remove it from pool
c.close() // whatever the pool uses to close a connection
// do not return it to the idle list
} else {
// return to pool as before
}- Robustness: detect SES 421 "Maximum message count per session reached"
If a send fails with a 421 and the text contains"Maximum message count per session reached"(or if the server closed the connection unexpected), mark that connection dead (close & drop it) and retry the same message once on a fresh connection without counting it as a normal failure. That will make smtppool handle provider-side forced disconnects gracefully.
Add a tiny helper:
func isMaxMsgPerSessionErr(err error) bool {
if err == nil { return false }
s := strings.ToLower(err.Error())
return strings.Contains(s, "maximum message count per session") ||
strings.Contains(s, "maximum message count") // be permissive
}Then in the Send code, on error:
if isMaxMsgPerSessionErr(err) {
// close that conn, remove from pool
c.close()
// attempt once more on a fresh conn
return p.Send(e)
}Notes:
- Thread safety: the pool's existing locking logic should be used to remove/close a conn to avoid races.
- Default behavior (if
MaxMessagesPerConn==0) must be unchanged. - Add tests: unit tests that simulate a conn returning 421 and that conn is closed and message retried.
B — listmonk changes (to pass the option into smtppool)
File: internal/messenger/email/email.go (the file that builds the smtppool options; you can see it sets s.Opt.*). ([GitHub][2])
- Add a new field to the SMTP server config struct:
type Server struct {
Name string
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
AuthProtocol string `mapstructure:"auth_protocol"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
SendTimeout time.Duration `mapstructure:"send_timeout"`
MaxConns int `mapstructure:"max_conns"`
// New: maximum messages to send per connection before the pool closes it.
MaxMsgsPerConn int `mapstructure:"max_msgs_per_conn"`
mailer *smtppool.Pool
}- When you build the
smtppool.Opt, set the new value:
Find the place where the code sets s.Opt.* and add:
s.Opt.MaxMessagesPerConn = s.MaxMsgsPerConn(If listmonk imports smtppool/v2, set the same field name on smtppool.Opt.)
- Expose the option to config / UI
- Add
max_msgs_per_connto the config sample (TOML) and to the Settings → SMTP UI (the DB field + settings UI), so you can tune it without rebuilding. - If you prefer to test first: you can hardcode the number in the
Serverstruct while testing, then expose it to UI later.
I previously submitted a "retry delay" PR to attempt to solve this, but I'm now thinking that the retry delay would not resolve this problem, and this is a better/actual solution.
Thoughts knadh?