Docs / listmonk

Send newsletters from listmonk over SMTP

listmonk is a self-hosted newsletter and mailing list manager: it keeps the subscribers and sends campaigns, and delivery is left to the SMTP servers you give it. It keeps a pool of connections to each one and throttles itself, so the settings that matter are the pool, the rate and what happens to bounces and unsubscribes. Below is the setup for Postwing.

✓
You can get them on the token management page. For security reasons, a token is shown only once — at the moment it is created.

SMTP connection settings

SettingValue
SMTP hostsmtp.postwing.app
Port587
EncryptionSTARTTLS (the connection is upgraded to TLS before login)
UsernameThe login of an SMTP token for your domain
PasswordThe password of that token — shown once, when the token is created
ℹ
Every mode is also available on a high port: 8465 (SSL/TLS), 8587 (STARTTLS) and 8025 (plain). Many hosting providers and clouds block outbound 25, 465 and 587 — if the connection times out, switch to the matching high port.

Add the SMTP server

Go to Settings → SMTP and edit the existing block or add a new one:

FieldValue
EnabledOn
Hostsmtp.postwing.app
Port587
Auth protocolPLAIN
UsernameThe login of an SMTP token for your domain
PasswordThat token's password
TLSSTARTTLS (SSL/TLS with port 465)
Skip TLS verificationOff
HELO hostnameOptional — the listmonk server's name
⚠
The relay authenticates with AUTH PLAIN and does not offer LOGIN. With LOGIN selected, listmonk fails to authenticate even when the username and password are right.

The campaign's From address — and the Default `from` email under Settings → General — must be on your verified domain.

Pool size and timeouts

SettingDefaultRecommendation
Max. connections10Keep 10 or lower — more does not raise your plan limit
Retries2Keep; a retry uses another connection from the pool
Idle timeout15sKeep; an idle connection is closed and dropped from the pool
Wait timeout5sKeep

Save, then use Test connection on the block to send a message to your own address.

Rate limiting

Your plan's hourly limit is enforced by the relay: past it, every message is refused with 550 hourly limit exceeded, and after Maximum error threshold failures listmonk pauses the campaign. Let listmonk pace itself instead — in Settings → Performance:

  • Enable sliding window limit on, Duration1h, Max. messages a little under your hourly limit.
  • Concurrency × Message rate is the per-second ceiling; the defaults are fine once the window is set.

A new domain's warm-up is different: it never refuses, and over-cap mail is delivered later. See sending limits.

Unsubscribes

With Settings → Privacy → Include `List-Unsubscribe` header on, listmonk adds the one-click unsubscribe pair to every campaign message. When we classify a message as bulk, the relay replaces that pair with its own: the unsubscribe then lands in the domain's unsubscribe list, and later mail to that address is dropped instead of delivered. listmonk still shows the subscriber as active — the unsubscribed webhook tells you who to update. The unsubscribe link in the template itself still goes to listmonk.

Bounces

Turn on Settings → Bounces → Enable bounce processing, set the actions (hard bounce: count 1, action Blocklist), and feed it from one or both sources:

  • Bounce mailbox (POP3). Bounce messages go to the envelope sender — the campaign's From, or a Return-Path set in the SMTP block's custom headers. It must be on your verified domain, and the domain's return path in the dashboard must be empty, or it overrides listmonk's.
  • Bounce webhooks. A rejection our servers receive during delivery never comes back as a bounce message — it is a bounced webhook. Enable bounce webhooks, create an API user whose role has the webhooks:post_bounce permission, and forward the events:
bounce_bridge.py
# Forwards bounced / complained webhook events to listmonk's bounce API.
import hashlib, hmac, os, time
import requests
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["WEBHOOK_SECRET"]           # the endpoint secret
LISTMONK = "https://lists.your-domain.com"
API_AUTH = ("bounce-bot", os.environ["LISTMONK_TOKEN"])  # listmonk API user
TYPES = {"bounced": "hard", "complained": "complaint"}

@app.post("/hooks/delivery")
def delivery():
    raw = request.get_data(as_text=True)
    ts = request.headers["X-Webhook-Timestamp"]
    sig = request.headers["X-Webhook-Signature"]
    expected = hmac.new(SECRET.encode(), f"{ts}.{raw}".encode(), hashlib.sha256).hexdigest()
    if abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(expected, sig):
        abort(400)

    event = request.get_json()
    if event["event"] in TYPES:
        requests.post(f"{LISTMONK}/webhooks/bounce", auth=API_AUTH, timeout=10, json={
            "email": event["email"],
            "source": "relay",
            "type": TYPES[event["event"]],
        }).raise_for_status()
    return "", 204

Troubleshooting

ErrorCause and fix
Authentication fails with correct credentialsAuth protocol is LOGIN or None — set it to PLAIN.
Test connection times out Outbound port blocked — use 8587 with STARTTLS or 8465 with SSL/TLS.
550 from must be equal to … The From address or the custom Return-Path is not on your verified domain.
Campaign paused, 550 hourly limit exceededEnable the sliding window limit, then resume.
Bounce webhook returns 404Enable bounce webhooks under Settings → Bounces.
Mail goes to spam From is not on your verified domain, or the list has unconfirmed or stale addresses.

Frequently asked questions

Which Auth protocol should I choose in listmonk?

PLAIN. The relay offers AUTH PLAIN over an encrypted connection and does not offer LOGIN, so listmonk's LOGIN option fails authentication even with the right credentials. A new SMTP block starts at None, which sends no credentials at all.

What should Max. connections be?

Start at the default of 10, or lower. More connections only help if listmonk's workers are waiting on the network; they do not raise your plan's hourly limit, and a campaign faster than that limit just reaches the refusal sooner.

How do I stop a campaign from exceeding my hourly limit?

Settings → Performance → Enable sliding window limit, with Duration 1h and Max. messages a little under your plan's hourly figure. listmonk then holds messages back until the window clears instead of pushing them into a 550 refusal.

Does listmonk add a List-Unsubscribe header?

Yes, when Settings → Privacy → Include List-Unsubscribe header is on: it adds List-Unsubscribe and the one-click List-Unsubscribe-Post. On messages we classify as bulk, the relay replaces that pair with its own, so the unsubscribe is recorded on our side rather than in listmonk.

Why is my campaign paused with errors?

listmonk pauses a running campaign after Maximum error threshold failures (Settings → Performance). With a relay, the usual cause is 550 hourly limit exceeded — turn on the sliding window, then resume the campaign.

How does listmonk learn about bounces?

Either from a POP3 bounce mailbox that receives the bounce messages, or through its bounce webhook API. Rejections our servers receive during delivery never arrive as a bounce message, so the webhook bridge below is what catches those.

Next steps