Skip to content

Solving 421 Maximum message count per session reached errors #18

@MaximilianKohler

Description

@MaximilianKohler

I'm attempting to solve the 421 Connection closed by server. Maximum message count per session reached. errors. Discussed in this thread: knadh/listmonk#1717

I submitted a PR for a retry delay: knadh/listmonk#2309

Chat GPT says it won't fix the issue, and it may be right.

I suggested adding "Max messages per SMTP connection": knadh/listmonk#2683

GPT says the correct fix is not to knadh/listmonk but rather to knadh/smtppool.

What would actually fix SES 421 cleanly

One of these must happen:

✅ Option A (best, architectural)

Add per-connection message limits to smtppool

Close and replace the connection before SES does

This is what we discussed earlier (MaxMessagesPerConn)

This belongs in smtppool, not listmonk

Next step (when you’re ready)

When you follow up about smtppool, I can:

Draft the exact PR for knadh/smtppool

Include SES-specific 421 handling

Add MaxMessagesPerConn cleanly

Make it upstream-acceptable

Should I continue with that? I'm unsure if SES-specific 421 handling is desirable.

EDIT: I asked GPT about the SES-specific part and it said:

That’s a very good instinct, and you’re right to be cautious. Let me clarify what “SES-specific” means here — and how to do this without hard-coding SES behavior or breaking generic SMTP correctness.

You should not add “if SES then …” logic anywhere.

The correct fix is SMTP-generic:

If the server closes the connection or returns a 421 during DATA / send, the client must discard that connection and retry on a fresh one.

That behavior happens to fix SES — but it is valid SMTP behavior for any server.

The full GPT response

That’s a very good instinct, and you’re right to be cautious. Let me clarify what “SES-specific” means here — and how to do this without hard-coding SES behavior or breaking generic SMTP correctness.


Short answer (TL;DR)

You should not add “if SES then …” logic anywhere.

The correct fix is SMTP-generic:

If the server closes the connection or returns a 421 during DATA / send, the client must discard that connection and retry on a fresh one.

That behavior happens to fix SES — but it is valid SMTP behavior for any server.


Why SES triggers this more often

Amazon SES enforces:

  • a maximum number of messages per SMTP session

  • once exceeded, it responds with:

    421 Connection closed by server. Maximum message count per session reached.
    
  • and closes the TCP connection

SES is just being explicit about it.

Other SMTP servers may:

  • silently drop the connection
  • respond with 421, 451, or just EOF
  • enforce limits based on time, message count, or size

SES is not unique — it’s just well-documented and predictable.


What SMTP RFCs say (why this is generic)

From RFC 5321 (SMTP):

  • 421 = Service not available, closing transmission channel

  • Client behavior:

    • The connection is no longer usable
    • The client must reconnect to continue sending mail
    • Retrying on the same connection is invalid

So a correct SMTP client must:

  1. Treat 421 as a connection-terminal error
  2. Close and discard that connection
  3. Retry the message on a new connection (subject to retry policy)

That is not SES-specific — that is SMTP-correct.


What not to do (this would be SES-specific and wrong)

❌ Matching the literal error string:

if strings.Contains(err.Error(), "Maximum message count per session") {
    // SES hack
}

❌ Hard-coding message limits like “1000 per connection” in listmonk

❌ Detecting SES by hostname and branching behavior

Those would indeed be inappropriate.


What is appropriate (generic, correct, upstreamable)

1. Treat 421 as a dead connection

In smtppool, when a send returns an error that is:

  • SMTP code 421
  • or EOF / broken pipe during DATA
  • or “connection closed” mid-send

then the pool must:

  • close and discard that connection
  • not return it to the pool
  • optionally retry the message once on a new connection

This logic applies to all SMTP servers.


2. Optional (but ideal): connection lifetime limits

Separately from error handling, adding:

MaxMessagesPerConn

is also SMTP-agnostic:

  • Many MTAs enforce:

    • max messages per session
    • max time per session
  • Limiting connection lifetime proactively is standard practice

  • Postfix, Exim, and Sendmail all do this

This is preventive, not SES-specific.


Why listmonk alone can’t fix this correctly

listmonk:

  • Calls pool.Send(em)

  • Does not control:

    • which connection is used
    • whether it’s dead or alive
    • when it’s reused

So:

  • retry logic in listmonk cannot guarantee a new connection
  • only the pool can do that correctly

That’s why the fix belongs in smtppool.


How to describe this in a PR (important wording)

If/when you open a PR to knadh/smtppool, I strongly recommend language like:

This change improves SMTP correctness by discarding and recreating pooled connections when the server closes the session (e.g. on SMTP 421 responses or unexpected EOF). This behavior is SMTP-generic and required by RFC 5321. It also prevents issues with MTAs that enforce per-session message limits.

Notice:

  • No mention of SES
  • No provider-specific wording
  • Pure protocol correctness

Mental model (why this is the right abstraction)

Think of it like HTTP keep-alive:

  • If the server closes the socket
  • the client must not reuse it
  • and must retry the request on a new connection

SMTP pooling is the same.

SES just happens to be a very visible case where incorrect reuse causes failures.


Bottom line

You’re absolutely right:

  • listmonk should remain SMTP-agnostic
  • SES should not be special-cased

The correct fix:

  • Is protocol-correct
  • Belongs in smtppool
  • Benefits all SMTP providers
  • Happens to fix SES 421 as a consequence

When you’re ready, I can help you:

  • phrase the smtppool PR correctly
  • implement the logic in a generic way
  • anticipate maintainer objections and address them cleanly

You’re thinking about this exactly the right way.

So I'll go ahead with the PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions