MailWizz has become the self-hosted email marketing platform of choice for high-volume senders who need PowerMTA-level infrastructure control without building a custom sending stack. It is genuinely powerful, well-maintained by its developer (Serban Constantin, who is responsive and has been consistently improving the platform since 2013), and handles the operational complexity of multi-client, multi-domain sending better than most alternatives at its price point. It also has a learning curve that the documentation does not fully prepare you for, and several operational patterns that are non-obvious until you have broken something in production and worked out why.
This guide is not the documentation — it is the documentation gap-filler. The MailWizz docs tell you what each setting does; this tells you which settings matter, how they interact, and what the decisions look like in a real production environment where you are managing 60 domains, 128 IPs, and multiple client accounts simultaneously.
MailWizz List Architecture: What the Abstractions Actually Mean
MailWizz organises contacts through a three-layer hierarchy that maps to most standard email operations, but has specific implications that are worth understanding before you design your deployment:
Lists are the core data structure. A list is a collection of subscribers with shared characteristics — typically a single brand's email subscribers, or a segment of a client's database. Lists have their own settings: from name, from email, reply-to, notification settings, and critically, their own delivery server assignment. Subscribers belong to lists; the same email address can appear in multiple lists independently, and a global unsubscribe (subscriber clicks "unsubscribe from all") applies across all lists unless you configure otherwise.
Campaigns draw from lists. A campaign sends to all subscribers on a list, or to a segment of a list defined by subscriber custom fields. Multiple campaigns can reference the same list; the list is the persistent data store, the campaign is the send event.
Customer accounts (in multi-tenant MailWizz deployments where you are running it as an ESP for multiple clients) own lists and campaigns. A customer account's lists are isolated by default — Customer A cannot see Customer B's lists. But the global suppression list, the global bounce handling, and the delivery server pool are shared across all customer accounts unless you configure delivery server groups to create isolation.
The practical implication of this hierarchy: in a multi-client deployment, the decisions you make at the global level (global suppression list policy, delivery server pool, bounce handling thresholds) affect all clients simultaneously. If you add a sending domain to the global suppression list because one client abused it, that domain is suppressed for all clients. If you set a global soft bounce threshold of 3 bounces before unsubscribe, that threshold applies to every list across every client account. Think through the global-level policies before you start onboarding clients, because changing them retroactively is operationally painful.
Global vs List-Level Suppression: When Each Applies
MailWizz has three suppression mechanisms that operate at different levels and have different behaviours. Understanding which applies when is critical for correct list management:
Global suppression list (Backend → Email blocklist): A platform-level list of email addresses and domains that will never receive email from any list on the platform. Applies across all customer accounts. Adds are permanent — there is no automatic expiry. Use this for addresses that have hard bounced across multiple client accounts, addresses associated with spam trap activity, and domains that should never receive email from the platform (competitor domains you want to exclude, for example). This list is evaluated at campaign send time — an address added to the global blocklist after a campaign is queued but before it sends will be excluded from the send.
List-level unsubscribes (per-list): When a subscriber clicks unsubscribe in an email, MailWizz marks them as "unsubscribed" on that specific list. By default, an unsubscribe from List A does not affect the subscriber's status on List B — the same email address can be unsubscribed from one list while remaining active on another. This is the correct behaviour for most use cases (a subscriber may want newsletters from Brand X but not from Brand Y even if they are in the same MailWizz deployment). However, for a single-brand deployment where all lists represent segments of the same subscriber base, you likely want global unsubscribe to cascade across all lists — configure this in each list's settings under "Subscriber unsubscribe action."
Complaint/FBL suppression: When a subscriber marks your email as spam at their ISP and the FBL (Feedback Loop) complaint report reaches MailWizz's bounce handler, MailWizz marks the subscriber as "moved to blacklist" on the specific list. Complaint handling requires that your bounce handling email account (the address where Gmail, Yahoo, and Microsoft send complaint reports) is configured in MailWizz's bounce servers. Without this, FBL complaints arrive at the address but MailWizz does not process them, and the complained-about subscriber continues receiving email — which continues generating complaints that degrade your sending reputation.
# Checking suppression status via MailWizz API:
# GET /api/v1/lists/{list_uid}/subscribers/search
curl -X GET "https://your-mailwizz.com/api/v1/lists/LIST_UID/subscribers/search" -H "X-MW-PUBLIC-KEY: your_api_key" -d "email=subscriber@example.com&status=unsubscribed"
# Response includes: status (active/unsubscribed/bounced/moved-to-blacklist/confirmed)
# Note: "moved-to-blacklist" = complaint or global blocklist match
# Note: "bounced" = hard bounce
# Check global blocklist for an address:
# No API endpoint — must query the database directly or use admin interface
# Backend → Email blocklist → search
Bounce Handling Configuration That Actually Works
The bounce handler is the MailWizz component that processes non-delivery reports, FBL complaints, and out-of-office messages from the bounce processing email account. It is also the most frequently misconfigured component in MailWizz installations I've seen. The configuration that actually works in production:
# MailWizz Bounce Processing Setup: # Backend → Servers → Bounce servers → Add new # Server type: IMAP # Hostname: your bounce email server (e.g., mail.yourserver.com) # Port: 993 (IMAP SSL) or 143 (IMAP STARTTLS) # Username: bounces@yourdomain.com (or whatever address you set as return-path) # Password: [account password] # Protocol: IMAP # Validate SSL: Yes (unless using self-signed cert on internal server) # The bounce handler checks this inbox and processes: # - Hard bounces (5xx permanent failures): marks subscriber as "bounced" # - Soft bounces (4xx temporary failures): increments soft bounce counter # - FBL complaints: marks subscriber as "moved-to-blacklist" # - Out-of-office: no action by default # Critical setting: soft bounce threshold # In Backend → Settings → Customers → Max soft bounces: # Recommendation: 3 soft bounces → unsubscribe # Default is higher, which means email continues to soft-bouncing addresses # longer than ISPs want to see # Verify bounce processing is working: # Check Backend → Misc → Application log for bounce processor entries # Should see regular entries like: # "Processing bounce for: address@domain.com - Hard bounce - 550 5.1.1" # If the log shows no bounce processing for 24 hours after sending, the # bounce server connection is not working
The bounce processing email address needs to be configured consistently with your sending infrastructure. MailWizz sets the Return-Path header (the envelope sender, where bounces return) to the bounce processing address. This must be a valid, monitored email address on a domain with a valid SPF record that includes your sending infrastructure. If the Return-Path domain fails SPF authentication, some receiving servers will not generate bounce reports to that address — meaning your bounces go to /dev/null and hard-bouncing addresses continue on your list indefinitely.
One non-obvious bounce handling issue: the FBL processing. Gmail, Yahoo, and Microsoft all send complaint reports in different formats (Gmail uses Gmail's own format; Yahoo uses ARF; Microsoft JMRP uses a slightly different ARF). MailWizz's bounce handler understands these formats, but it only receives them if: (1) you are enrolled in the FBL/JMRP programme for the relevant ISP, (2) the enrolled email address matches the bounce processing inbox, and (3) the bounce server is checking that inbox and processing the messages. In a fresh MailWizz installation, none of these are configured — you must set up FBL enrollment separately for each ISP and then configure MailWizz's bounce server to process the incoming complaint messages from that same inbox.
Delivery Server Assignment and List-Level Routing
Delivery servers in MailWizz are the SMTP credentials that tell MailWizz how to send email. In a PowerMTA + MailWizz setup, each delivery server entry typically represents a VMTA or a specific source IP configuration on PowerMTA. The assignment of delivery servers to lists is the mechanism that implements reputation isolation — the same mechanism that in pure PowerMTA is handled by VMTA pools and transport maps.
# Delivery server assignment hierarchy in MailWizz: # Option 1: Customer-level assignment # Backend → Customers → [customer] → Sending → Allowed delivery servers # This restricts which delivery servers a customer can assign to their lists # Use this in multi-tenant deployments to prevent customers from selecting # delivery servers belonging to other clients # Option 2: List-level assignment (preferred for reputation isolation) # Lists → [list] → Sending → Delivery servers # Assign one or more delivery servers to a specific list # MailWizz rotates across assigned servers using the "probability" weighting # Delivery server group assignment (best for scale): # Backend → Servers → Delivery server groups → Create group # Add multiple servers to a group → assign the group to lists # This allows pool-level rotation without individual server assignment to each list # Example: separate pools for different email types # "Transactional Pool" → delivery servers on dedicated transactional IPs # "Marketing Pool" → delivery servers on marketing IP range # "Cold Email Pool" → delivery servers on cold email IPs (if applicable) # Assign lists to pools based on email type, not individually to servers
The probability weighting on delivery servers within a list is something many MailWizz operators set and then forget. When you assign three delivery servers to a list with probabilities 50, 30, 20, MailWizz distributes sends roughly in those proportions. This is useful during IP warmup — you can assign a new IP at probability 10 alongside established IPs at higher probabilities, and gradually increase the new IP's probability as its reputation builds. In PowerMTA terms, this is equivalent to adjusting the VMTA pool composition over time as warmup progresses.
One limitation of MailWizz's delivery server assignment: it is not destination-aware. You cannot configure "send to Gmail recipients via Server A and send to Microsoft recipients via Server B" directly in MailWizz's UI. That level of per-destination routing requires PowerMTA transport maps configured at the PowerMTA level, with MailWizz simply submitting all mail to PowerMTA and letting PowerMTA handle the per-ISP routing. This is the correct architecture for production high-volume sending — MailWizz handles the subscriber management, campaign logic, and SMTP submission; PowerMTA handles the delivery routing, rate limiting, and connection management.
List Synchronisation and Subscriber Import Workflows
MailWizz subscriber imports — adding contacts to a list from an external CRM, database, or file — have several behaviours that are worth understanding before you build your import workflow:
Import status handling: When you import a subscriber who already exists on the list, MailWizz has configurable behaviour: update the existing record, skip it, or unsubscribe it. The default is "update" — which means an import that includes previously unsubscribed addresses will re-activate them unless you configure the import to skip existing records. For any import from an external source that may contain previously unsubscribed addresses, use "skip if already exists" to prevent accidental resubscription of opted-out contacts.
CSV import field mapping: MailWizz's CSV import maps columns to subscriber custom fields. The email column and first name/last name are standard; all other data goes into custom fields that must be defined on the list before import. Create all necessary custom fields (engagement score, last purchase date, geographic region, whatever segmentation data you need) before running the first import, not after — retroactively adding fields requires re-importing the data or manually updating subscriber records.
Import via API (preferred for automation):
# MailWizz API: add or update a subscriber on a list
curl -X POST "https://your-mailwizz.com/api/v1/lists/LIST_UID/subscribers" -H "X-MW-PUBLIC-KEY: your_api_key" -H "Content-Type: application/json" -d '{
"EMAIL": "subscriber@example.com",
"FNAME": "Jane",
"LNAME": "Smith",
"status": "confirmed",
"CUSTOM_FIELD_1": "value",
"CUSTOM_FIELD_2": "value"
}'
# Response: {"status":"success","data":{"record":{"subscriber_uid":"..."}}}
# For bulk imports via API (more efficient than one-at-a-time):
# Use the bulk import endpoint: POST /api/v1/lists/{list_uid}/subscribers/bulk-subscribe
# This accepts arrays of subscriber objects in a single API call
# Rate limit: 100 subscribers per request recommended; use pagination for larger sets
# Suppression sync: removing unsubscribes from an external system
curl -X DELETE "https://your-mailwizz.com/api/v1/lists/LIST_UID/subscribers/UID/unsubscribe" -H "X-MW-PUBLIC-KEY: your_api_key"
Sending Limits, Warmup Plans, and the Math Behind Them
MailWizz's Warmup Plans feature (introduced in MailWizz 2.x) automates the gradual volume ramp during new domain or IP warmup. Understanding what the numbers mean is essential for configuring plans that actually match your warmup goals:
# MailWizz Warmup Plan settings (Backend → Servers → Warmup Plans): # # Type: "Total" vs "Targeted" # - Total: sends flat equal volume every day (e.g., 100/day for all 14 days) # - Targeted: ramps incrementally toward the "Sending limit" value # # Sending limit: the maximum daily quota reached on the FINAL day of the plan # This is NOT the total volume for the entire plan # This is NOT a per-campaign limit # It IS the highest daily volume the warmup plan allows # # Example: Targeted plan, 14 days, sending limit 160: # Day 1: ~11 emails (160/14 ≈ 11.4 per day increment) # Day 2: ~23 emails # Day 3: ~34 emails # ... # Day 14: 160 emails # Total across plan: ~1,160 emails (sum of arithmetic sequence) # # For a 60-domain / 128-IP setup sending warm-up on all domains simultaneously: # Each domain gets its own warmup plan # Warmup plan is assigned to the delivery server, not the list # All lists sharing that delivery server share the warmup quota # The gotcha: warmup plan limits apply per-delivery-server, not per-list # If two lists share a delivery server with a 100/day warmup limit, # the combined sends from BOTH lists cannot exceed 100/day # This is correct behaviour but bites operators who expect per-list limits
For the specific warmup question around exponential vs linear strategies: MailWizz's "Targeted" type is linear (it adds a fixed increment each day). True exponential warmup — where volume doubles every 3-4 days — requires either manual daily adjustment of the delivery server sending limits or a custom script that updates limits via the MailWizz API on a schedule. For most single-domain warmup scenarios, linear progression with a 14-day timeline to reach 100-200 daily sends is sufficient. Exponential warmup is more appropriate for IP warmup on dedicated IPs being onboarded into a high-volume sending programme.
API-Based List Management for Automated Workflows
MailWizz's REST API covers enough that all routine list management operations should be API-driven rather than manual in a production environment. The operations I run through the API rather than the UI in a 60-domain setup:
#!/usr/bin/env python3
"""MailWizz production list management utilities"""
import requests
import json
MAILWIZZ_BASE = "https://your-mailwizz.com/api/v1"
API_KEY = "your_api_key_here"
HEADERS = {"X-MW-PUBLIC-KEY": API_KEY, "Content-Type": "application/json"}
def get_list_stats(list_uid):
"""Get subscriber counts by status for a list"""
resp = requests.get(f"{MAILWIZZ_BASE}/lists/{list_uid}", headers=HEADERS)
data = resp.json()
return {
"total": data.get("data", {}).get("record", {}).get("subscribers_count", 0),
"list_uid": list_uid
}
def bulk_unsubscribe_by_csv(list_uid, csv_file):
"""Unsubscribe a list of email addresses from a list (opt-out processing)"""
with open(csv_file) as f:
emails = [line.strip() for line in f if '@' in line]
results = {"success": 0, "failed": 0}
for email in emails:
# First find the subscriber
search = requests.get(
f"{MAILWIZZ_BASE}/lists/{list_uid}/subscribers/search",
headers=HEADERS,
params={"email": email}
)
data = search.json()
records = data.get("data", {}).get("records", [])
if not records:
continue
sub_uid = records[0]["subscriber_uid"]
# Unsubscribe
result = requests.put(
f"{MAILWIZZ_BASE}/lists/{list_uid}/subscribers/{sub_uid}/unsubscribe",
headers=HEADERS
)
if result.status_code == 200:
results["success"] += 1
else:
results["failed"] += 1
return results
def add_to_global_suppression(emails):
"""Add email addresses to MailWizz global email blocklist"""
# Note: as of MailWizz 2.6.x there is no public API endpoint for
# the global blocklist — this requires direct database access or
# use of the admin UI. This is a known limitation.
# Workaround: add to a shared "suppression" list that all campaigns
# are configured to exclude via the "Exclude lists" campaign setting
pass # Implement via database or UI
def campaign_stats_summary(campaign_uid):
"""Get delivery statistics for a campaign"""
resp = requests.get(
f"{MAILWIZZ_BASE}/campaigns/{campaign_uid}",
headers=HEADERS
)
data = resp.json().get("data", {}).get("record", {})
return {
"sent": data.get("emails_sent", 0),
"delivered": data.get("emails_delivered", 0),
"bounced": data.get("bounces_count", 0),
"unsubscribed": data.get("unsubscribes_count", 0),
"complaints": data.get("complaints_count", 0),
}
The MailWizz Quirks That Nobody Documents
These are the things I have hit in production that cost time to debug because they are not in the documentation:
Campaign pause behaviour: When you pause a running campaign, MailWizz stops queueing new subscribers for delivery but does not stop delivery of messages already in the queue. If you have 50,000 messages queued in PowerMTA when you pause the MailWizz campaign, those 50,000 messages will continue delivering until the PowerMTA queue is empty. The MailWizz pause controls the injection rate into PowerMTA, not the PowerMTA queue itself. To immediately stop all delivery from a paused campaign, you must also flush the relevant queue in PowerMTA — which is a separate administrative operation.
Warmup plan "carry-over" behaviour: If a warmup plan allows 100 emails on Day 3 but a campaign only sends 60, MailWizz does NOT carry the unused 40 forward to Day 4. Day 4's limit is calculated fresh based on the plan progression, not augmented by unused quota from prior days. This is the correct behaviour for warmup (you want consistent progression, not variable daily sends), but it surprises operators who expect unused quota to accumulate.
List subscriber count caching: MailWizz caches subscriber counts on list records and updates them asynchronously. If you run a large import (100,000 subscribers) and immediately check the list's subscriber count in the UI, it will show the pre-import count. The actual count updates on the next background job run (typically within a few minutes). Do not trigger campaigns based on immediately post-import subscriber counts — wait for the count to stabilise.
The global blocklist API gap: There is no public API endpoint for adding or removing entries from the global email blocklist (Backend → Email blocklist). Any automated blocklist management must go through the admin UI or direct database queries. This is the most significant operational gap in MailWizz's API coverage — in a production environment where you want to add hard-bouncing addresses to the global suppression list automatically, you need either direct database access or a periodic script that reads the bounce logs and makes the database inserts directly.
Test campaign delivery server selection: When sending a test email in MailWizz, the delivery server used for the test is selected differently from production sends. Test emails use the first available delivery server on the system, not necessarily the delivery server assigned to the list. This means a list assigned to a dedicated transactional IP may have its test emails sent from a marketing IP. Verify test email authentication by checking the received headers — do not assume the test email used the correct delivery server just because the UI showed it was sent.
MailWizz at scale is a production infrastructure component, not a drag-and-drop email tool. The operators who get the most out of it treat it that way — using the API for automation, monitoring the bounce handler logs daily, understanding the warmup plan mathematics before configuring them, and building the PowerMTA integration that makes the combined stack do what neither can do alone. The operators who struggle are those who configure it once and expect it to run itself. At the volumes and complexity levels where self-hosted MailWizz makes sense, it requires the same operational attention as any production infrastructure component. Give it that attention, and it performs reliably at scale.