Updated: 28 September 2026 · Applies to: Postfix 3.8 (Ubuntu 24.04) and 3.10 (Ubuntu 26.04) with shell scripts and cron
Say you send mail for many domains or customers from one server that has many IP addresses. Doing every job by hand takes too much time. Small scripts can do the boring jobs for you, and Ucartz's Acceptable Use Policy allows this. With scripts you may:
- give each domain or customer its own IP address, and keep it the same every time;
- keep your IP addresses in a group (a pool), while each mail stream stays on its own fixed IP;
- start a new IP slowly and send a little more every few days (this is called warm-up);
- limit how fast you send to each mail provider, such as Gmail or Yahoo;
- check every IP for problems automatically;
- pause or slow down a stream when its IP shows trouble: mail is delayed, the IP is on a blocklist, many mails bounce, or many people click "report spam".
This guide shows how to do these things with Postfix and small scripts.
What to automate, and what not to
Good to automate: choosing the IP for each domain, raising the sending limits day by day, checking the health of each IP, sending an alert to a person, and pausing a stream that has a problem.
Do not automate this: a script that moves your mail to a new IP after the old IP was blocked, so that you can keep sending. This does not work, and our Acceptable Use Policy does not allow it. Here is why:
- A block is not caused by the IP. It is caused by the mail: a bad list, too many spam complaints, or sending too fast. The new IP carries the same mail, so it gets blocked in the same way.
- A new IP has no good name yet. Mail providers are careful with new IPs, so they often block them faster.
- Some blocklists block the whole group of neighbouring IPs. Then the problem spreads to the other IPs on your server.
The safe way has five steps: (1) find the problem, (2) pause the stream, (3) tell a person, (4) fix the reason, (5) start again slowly. See What to do when your IP address is on a blocklist or your mail is rejected? for how to find and fix the reason.
1. One list as the single source of truth
Create /etc/mailstreams.csv. One line for each sending domain: the domain, the Postfix transport that sends from its address, the address, the HELO name and the date the address started sending.
# domain,transport,ip,helo,start-date shop.example.com,out1,203.0.113.11,mail1.example.com,2026-06-01 news.example.com,out2,203.0.113.12,mail2.example.com,2026-09-20
The transports themselves (out1, out2) are created once in master.cf as shown in How to make Postfix send from a specific IP address with a matching HELO name?, each with its own bind address and HELO name. Adding a new stream means one new transport and one new line in the list.
2. Generate the assignments
Save as /usr/local/bin/streams-apply.sh:
#!/bin/bash # Builds the sender-to-transport map from /etc/mailstreams.csv set -euo pipefail CSV=/etc/mailstreams.csv MAP=/etc/postfix/sender_transport tmp=$(mktemp) while IFS=, read -r domain transport ip helo start; do case "$domain" in ""|\#*) continue;; esac echo "@$domain $transport:" >> "$tmp" done < "$CSV" install -m 644 "$tmp" "$MAP" rm -f "$tmp" postmap "$MAP" postconf -e "sender_dependent_default_transport_maps = hash:$MAP" systemctl reload postfix echo "Applied $(wc -l < "$MAP") stream assignments."
Make it executable (sudo chmod +x /usr/local/bin/streams-apply.sh) and run it after every change to the list. The map is the one described in How to give each domain or customer its own sending IP address in Postfix?.
3. Warm-up on a schedule
A new address needs low limits that grow over the first weeks (How to warm up a new sending IP address step by step?). This script sets the Postfix limits of each transport from the number of days since its start date. The values are an example; adapt them to your lists and to the results you see.
#!/bin/bash
# /usr/local/bin/streams-warmup.sh - run once a day from cron
set -euo pipefail
CSV=/etc/mailstreams.csv
STATE=/var/lib/mailstreams
mkdir -p "$STATE"
changed=0
while IFS=, read -r domain transport ip helo start; do
case "$domain" in ""|\#*) continue;; esac
days=$(( ( $(date +%s) - $(date -d "$start" +%s) ) / 86400 ))
if [ $days -le 3 ]; then stage=1; delay=10s; rcpt=5; conc=2
elif [ $days -le 7 ]; then stage=2; delay=5s; rcpt=5; conc=2
elif [ $days -le 14 ]; then stage=3; delay=2s; rcpt=10; conc=3
elif [ $days -le 21 ]; then stage=4; delay=1s; rcpt=10; conc=5
else stage=5; fi
old=$(cat "$STATE/$transport" 2>/dev/null || echo 0)
[ "$old" = "$stage" ] && continue
if [ $stage -lt 5 ]; then
postconf -e "${transport}_destination_rate_delay = $delay" \
"${transport}_destination_recipient_limit = $rcpt" \
"${transport}_destination_concurrency_limit = $conc" \
"${transport}_destination_concurrency_failed_cohort_limit = 10"
else
postconf -X "${transport}_destination_rate_delay" "${transport}_destination_recipient_limit" \
"${transport}_destination_concurrency_limit" "${transport}_destination_concurrency_failed_cohort_limit"
fi
echo "$stage" > "$STATE/$transport"; changed=1
done < "$CSV"
[ $changed = 1 ] && systemctl reload postfix
exit 0
Postfix restarts the delay timers when it is reloaded, which is why the script reloads only when a stage changes. Run it daily: echo '30 2 * * * root /usr/local/bin/streams-warmup.sh' | sudo tee /etc/cron.d/streams-warmup. A stage moves forward only by the calendar; if a stream shows problems, pause it (next section) instead of letting the schedule continue.
4. Pause a stream automatically
Postfix can put new mail of one sender domain on hold. Held mail is not lost: it waits in the queue until you release it. This is a pause, not a reroute. In main.cf enable a pause list once:
sudo touch /etc/postfix/header_checks_pause sudo postconf -e "header_checks = regexp:/etc/postfix/header_checks_pause" sudo systemctl reload postfix
Save as /usr/local/bin/stream-pause.sh (usage: stream-pause.sh news.example.com). It needs jq (sudo apt install -y jq):
#!/bin/bash set -euo pipefail domain="$1"; file=/etc/postfix/header_checks_pause esc=$(echo "$domain" | sed 's/\./\\./g') grep -q "@$esc" "$file" || echo "/^From:.*@$esc/ HOLD stream paused by monitor" >> "$file" postfix reload # also hold what is already queued from this domain postqueue -j | jq -r --arg d "@$domain" 'select(.sender | endswith($d)) | .queue_id' | postsuper -h - || true echo "$domain paused"
Resume after you have fixed the cause. Save as /usr/local/bin/stream-resume.sh:
#!/bin/bash set -euo pipefail domain="$1"; file=/etc/postfix/header_checks_pause esc=$(echo "$domain" | sed 's/\./\\./g') sed -i "/@$esc/d" "$file" postfix reload postqueue -j | jq -r --arg d "@$domain" 'select(.sender | endswith($d)) | .queue_id' | postsuper -H - || true echo "$domain resumed"
Restart carefully: reset the start date of that stream in the list to today, run streams-warmup.sh and let the warm-up schedule start again (How to warm up a new sending IP address step by step?).
5. Watch each stream and pause on trouble
Because every stream has its own transport, the log shows the results per stream. Save as /usr/local/bin/streams-watch.sh and run it every 15 minutes from cron:
#!/bin/bash
CSV=/etc/mailstreams.csv
ALERT_TO="admin@example.com"
BOUNCE_PCT=10 # pause a stream above this share of bounced deliveries
MIN=100 # only judge a stream after this many results in the last hour
while IFS=, read -r domain transport ip helo start; do
case "$domain" in ""|\#*) continue;; esac
log=$(journalctl -t "postfix/$transport" --since "1 hour ago" --no-pager 2>/dev/null | grep "status=")
sent=$(echo "$log" | grep -c "status=sent"); bounced=$(echo "$log" | grep -c "status=bounced")
deferred=$(echo "$log" | grep -c "status=deferred"); total=$((sent + bounced + deferred))
[ "$total" -lt "$MIN" ] && continue
pct=$((bounced * 100 / total))
if [ "$pct" -gt "$BOUNCE_PCT" ]; then
/usr/local/bin/stream-pause.sh "$domain"
echo "$domain ($transport, $ip): $bounced of $total deliveries bounced in the last hour. Stream paused. Find the cause before you resume." \
| mail -s "Mail stream paused: $domain" "$ALERT_TO"
fi
done < "$CSV"
The same idea works for deferrals (many status=deferred lines mean the provider is slowing you down: lower the limits) and for complaints if you receive them through a feedback loop (How to process bounces and spam complaints and keep a suppression list?). The address checks (reverse DNS, port 25, queue size) are in How to monitor your sending IP addresses automatically with a script?.
6. Send alerts to your own automation (n8n)
If you run n8n (How to install n8n with Docker Compose and HTTPS on an Ubuntu VPS?), let the scripts post the alert to a webhook and let a workflow notify your team, open a ticket or write to a log sheet. In streams-watch.sh add, next to the mail command:
curl -s -X POST https://n8n.example.com/webhook/mail-alert \
-H "Content-Type: application/json" \
-d "{\"stream\":\"$domain\",\"transport\":\"$transport\",\"ip\":\"$ip\",\"bounced\":$bounced,\"total\":$total}"
How to create the webhook and what to check when it does not fire: How to fix n8n webhooks that do not work (test URL, production URL, wrong address)?. Use the Production URL of the workflow. Keep the workflow's job to informing people and recording what happened; the decision to resume a stream should stay with a person who has looked at the cause.
Common problems
- Nothing is held: the sender header does not contain the domain (check the
From:header of the message), orheader_checksis not set (postconf header_checks). - Per-stream statistics are empty: the mail does not use the stream's transport (How to give each domain or customer its own sending IP address in Postfix?) or the journal tag differs; look at
journalctl -t postfix/out1. - The warm-up script changes nothing: the start date in the list is in the past by more than 21 days, so the stream is at the final stage and has no limits.
Frequently asked questions
Can Ucartz build this for me?
Yes. Our engineers build and tune mail automation on your own server by the task, and our AI and automation team builds workflow and reporting on top of it.
Are these numbers rules?
No. They are starting values. Set them from your own results and from what the receiving providers show you.
Sources: Postfix configuration parameters and the Postfix header_checks manual (HOLD action).
Need a dedicated server, more IP addresses, or a hand with the setup?
- Unmanaged dedicated servers: full root access and IPv4 subnets from /29 up to /24, ordered with the server or added later.
- Managed dedicated servers: our team looks after the operating system, updates, security and monitoring.
- Dedicated server locations: choose the country and data centre when you order.
Prefer a hand with the setup? Our engineers can do it for you: Hire an Expert, or use our on-demand server management.
