Skip to content

Add setting: "Max messages per SMTP connection" to resolve "421 maximum message count per session reached" errors #2683

@MaximilianKohler

Description

@MaximilianKohler

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 does

I 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 (internally MaxMsgPerConn).

👉 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) exposes MaxConns, MaxMessageRetries, IdleTimeout, PoolWaitTimeout and TLS options — there is no MaxMessagesPerConn / MaxMsgPerConn field 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.Opt in 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.

  1. 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"`
}
  1. 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
}
  1. 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
}
  1. 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])

  1. 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
}
  1. 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.)

  1. Expose the option to config / UI
  • Add max_msgs_per_conn to 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 Server struct 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions