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.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
Go to Settings → SMTP and edit the existing block or add a new one:
| Field | Value |
|---|---|
| Enabled | On |
| Host | smtp.postwing.app |
| Port | 587 |
| Auth protocol | PLAIN |
| Username | The login of an SMTP token for your domain |
| Password | That token's password |
| TLS | STARTTLS (SSL/TLS with port 465) |
| Skip TLS verification | Off |
| HELO hostname | Optional — the listmonk server's name |
The campaign's From address — and the Default `from` email under Settings → General — must be on your verified domain.
| Setting | Default | Recommendation |
|---|---|---|
| Max. connections | 10 | Keep 10 or lower — more does not raise your plan limit |
| Retries | 2 | Keep; a retry uses another connection from the pool |
| Idle timeout | 15s | Keep; an idle connection is closed and dropped from the pool |
| Wait timeout | 5s | Keep |
Save, then use Test connection on the block to send a message to your own address.
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:
1h, Max. messages a little under your hourly limit. A new domain's warm-up is different: it never refuses, and over-cap mail is delivered later. See sending limits.
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.
Turn on Settings → Bounces → Enable bounce processing, set the actions (hard bounce: count 1, action Blocklist), and feed it from one or both sources:
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. bounced webhook. Enable bounce webhooks, create an API user whose role has the webhooks:post_bounce permission, and forward the events: # 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| Error | Cause and fix |
|---|---|
| Authentication fails with correct credentials | Auth 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 exceeded | Enable the sliding window limit, then resume. |
| Bounce webhook returns 404 | Enable bounce webhooks under Settings → Bounces. |
| Mail goes to spam | From is not on your verified domain, or the list has unconfirmed or stale addresses. |
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.
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.
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.
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.
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.
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.