The first time I set up a cron job with MAILTO=me@example.com, I assumed it would just work. It didn’t. The output ended up in /var/spool/mail/root, which I never checked, because my Oracle box couldn’t deliver outbound email — its IP was on every spam blocklist that exists, port 25 was blocked outbound by the cloud provider, and even if it hadn’t been, no major email provider would accept mail from a residential or VPS IP without proper SPF/DKIM.
The fix is to relay through a transactional email provider. I use Mailgun (free tier handles 5,000 messages/month, plenty for cron emails); SES, Resend, Postmark all work the same way. Here’s the postfix configuration that gets cron emails actually delivered.
Why direct delivery from a VPS doesn’t work
- Most cloud providers (Oracle, AWS, GCP) block outbound port 25 by default. You can request to unblock, but you usually can’t.
- Residential and small-VPS IPs are on Spamhaus PBL by default. Even if port 25 worked, Gmail and Outlook reject the mail.
- Without SPF/DKIM/DMARC, modern receivers send the mail straight to spam or reject it entirely.
Solving each of these manually is possible but expensive in time. Outsourcing the actual delivery to a transactional provider takes 15 minutes and works.
Postfix as a relay-only smarthost
Install postfix and configure it as “satellite” mode (no incoming mail, just outbound through a smarthost):
sudo apt install postfix mailutils libsasl2-modules
# When the dialog appears: choose "Satellite system"
# System mail name: your hostname.your-domain.com
# Smarthost: smtp.mailgun.org:587
Then edit /etc/postfix/main.cf to wire up authentication:
relayhost = [smtp.mailgun.org]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = encrypt
header_size_limit = 4096000
smtp_sasl_mechanism_filter = login
Create the password file:
cat > /etc/postfix/sasl_passwd <<EOF
[smtp.mailgun.org]:587 postmaster@mg.your-domain.com:YOUR-MAILGUN-SMTP-PASSWORD
EOF
sudo chmod 600 /etc/postfix/sasl_passwd
sudo postmap /etc/postfix/sasl_passwd
sudo systemctl restart postfix
The SASL credentials are in your Mailgun dashboard under “Domain settings → SMTP credentials.” Use the SMTP password, not the API key — they’re different things.
The “from address” trap
Mailgun (and SES, and most providers) only accept mail where the From address matches a domain you’ve verified with them. By default, postfix sends mail as root@<hostname>, which Mailgun rejects with a vague “5.7.1 Sender address rejected: not owned by user.”
Rewrite outgoing From addresses with a generic mapping:
# /etc/postfix/sender_canonical_maps
/.+/ cron@mg.your-domain.com
# /etc/postfix/main.cf — add:
sender_canonical_maps = regexp:/etc/postfix/sender_canonical_maps
sender_canonical_classes = envelope_sender, header_sender
Now every email leaving the box has cron@mg.your-domain.com as its From — Mailgun accepts it because the domain is verified there. Reload postfix: sudo systemctl reload postfix.
Test it
echo "test from $(hostname)" | mail -s "cron mail test" you@your-real-email.com
# Watch the postfix log
sudo tail -f /var/log/mail.log
You should see status=sent within a second or two. If it says status=bounced with a 5xx code, the bounce reason is in the log line — usually a SASL or sender-address problem.
Wiring it up to cron
# /etc/crontab
MAILTO=you@your-real-email.com
0 4 * * * root /usr/local/bin/nightly-job.sh
cron’s behaviour: any cron job that produces stdout or stderr causes cron to email the output to $MAILTO. Silent successes don’t email. Failures (non-zero exit + any output) do.
If you want explicit success/failure mails, put the email logic in your script:
#!/bin/bash
set -euo pipefail
LOG=$(/usr/local/bin/the-actual-job 2>&1) || {
echo "Job failed: $LOG" | mail -s "FAIL: nightly-job on $(hostname)" you@example.com
exit 1
}
The “what about Healthchecks.io” question
If you’ve already set up Healthchecks for cron heartbeats, you might wonder whether you also need email. The honest answer: Healthchecks is better for “did this job run?” — it catches silent failures email never would. Email is better for “what was the actual error?” — Healthchecks captures stderr but doesn’t make it as immediately visible as a mail to your inbox.
I run both. Healthchecks pings me when a job is missing. Email gives me the stderr when something did run but produced output. Together they cover the whole space.
The unsung benefit
Once postfix is relaying through Mailgun, every other tool on the box that wants to send email — logwatch, fail2ban‘s ban notifications, apt-listbugs, system update emails — all work. You configure smarthost once at the OS level; everything that uses /usr/sbin/sendmail inherits it.
The setup is 15 minutes. The result is that cron emails (and a dozen other system emails you didn’t realise were silently failing) actually arrive in your inbox. Worth it.
Cover photo: Solen Feyissa on Pexels.
