Rate limit outgoing emails from PHP web applications using postfix and policyd

One of the worst things a webmaster or a anyone else that runs some web application can do, is to constantly send “informative newsletters” to people. Most CMS applications make it really easy to send such emails. These are 99% spam, and as such there are many good reasons that you should limit the amount of such outgoing “newsletters” coming out of your email server. Else there’s a good chance you might get added to a blacklist, and you don’t want your legitimate clients to have their emails blocked because of some irresponsible people. I recently had to deploy such a solution to a hosting server that serves multiple (>300) domains. The server already ran postfix, so I had to implement something useful around it.

The problem with postfix is that you can’t really rate-limit the outgoing queue per sender domain/address. There are only generic settings that control the general mail server’s capabilities of sending emails. What I wanted though is to have the ability to restrict specific domains to some specific email message count per day. This is something that a postfix addon named postfix-policyd can do by deferring/greylisting, but still just on the incoming queue. One would think that the problems would be solved by just applying this, but truth is that they don’t. Applying a defer/greylisting policy on the incoming queue is fine while the client on the remote side is another SMTP server that can happily store the deferred email on its queue and retry some minutes/hours later. What happens though if the SMTP client is a PHP application that connects through the mail() function ? There you have no queue and if you defer a message at the SMTP server it will get forever lost, PHP can’t resend it. So the solution would be to apply an intermediate SMTP queue between PHP and the primary SMTP server, that is another local postfix installation that would only serve as a queue that relays emails to the primary.

Using a “simple” diagram sending an email from PHP should follow this path upon a successful installation:

PHP mail() –(sendmail binary)–> intermediate_POSTFIX –(SMTP relay)–> POSTFIX –(smtpd_sender_restrictions)–> POLICYD –(pickup)–> POSTFIX –(SMTP)–> REMOTE SERVER

Here are the steps I took on a Debian Squeeze server to install this little monster.

1. Create a new postfix configuration directory for the new intermediate postfix instance
I named my intermediate postfix config dir as postfix2525, name comes from the port that it will listen on but you can definitely be more creative.

# mkdir /etc/postfix2525
# cp -av /etc/postfix /etc/postfix2525

Remove everything from /etc/postfix2525/main.cf and just add the following lines:

data_directory = /var/lib/postfix2525
queue_directory = /var/spool/postfix2525
relayhost = 127.0.0.1:12525

This defines a new data and queue directory and instructs this postfix to relay all emails through another one that listens on the localhost, the primary one, on port 12525. More about this port later when you will create some special config on the primary postfix.

Remove previous contents of /etc/postfix2525/master.cf and just add these lines:

127.0.0.1:2525      inet  n       -       -       -       2       smtpd
        -o syslog_name=postfix2525
pickup    fifo  n       -       -       60      1       pickup
cleanup   unix  n       -       -       -       0       cleanup
qmgr      fifo  n       -       n       300     1       qmgr
#qmgr     fifo  n       -       -       300     1       oqmgr
tlsmgr    unix  -       -       -       1000?   1       tlsmgr
rewrite   unix  -       -       -       -       -       trivial-rewrite
bounce    unix  -       -       -       -       0       bounce
defer     unix  -       -       -       -       0       bounce
trace     unix  -       -       -       -       0       bounce
verify    unix  -       -       -       -       1       verify
flush     unix  n       -       -       1000?   0       flush
proxymap  unix  -       -       n       -       -       proxymap
proxywrite unix -       -       n       -       1       proxymap
smtp      unix  -       -       -       -       -       smtp
# When relaying mail as backup MX, disable fallback_relay to avoid MX loops
relay     unix  -       -       -       -       -       smtp
        -o smtp_fallback_relay=
#       -o smtp_helo_timeout=5 -o smtp_connect_timeout=5
showq     unix  n       -       -       -       -       showq
error     unix  -       -       -       -       -       error
retry     unix  -       -       -       -       -       error
discard   unix  -       -       -       -       -       discard
local     unix  -       n       n       -       -       local
virtual   unix  -       n       n       -       -       virtual
lmtp      unix  -       -       -       -       -       lmtp
anvil     unix  -       -       -       -       1       anvil
scache    unix  -       -       -       -       1       scache

Obviously the most important part here is the first line. It defines that this postfix instance will listen for SMTP connections on localhost, port 2525 and it’s syslog output name will be postfix2525 so that it’s easier to tell apart which SMTP instance spits which errors.

After this is done you need to run the following command that will create all necessary directories with their proper permissions.

# postfix -c /etc/postfix2525/ check

Also make sure you add the following line to the main.cf file of your main postfix installation:
alternate_config_directories = /etc/postfix2525

You will also need a new init script. Since the script by itself is quite big and there are only a few lines that actually differ, I will post my diff here:

--- /etc/init.d/postfix  2011-05-04 21:17:47.000000000 +0200
+++ /etc/init.d/postfix2525  2011-12-19 19:22:09.000000000 +0100
@@ -17,8 +17,10 @@
 # Description:       postfix is a Mail Transport agent
 ### END INIT INFO
 
+CONFDIR=/etc/postfix2525
 PATH=/bin:/usr/bin:/sbin:/usr/sbin
 DAEMON=/usr/sbin/postfix
+DAEMON_OPTIONS="-c /etc/postfix2525"
 NAME=Postfix
 TZ=
 unset TZ
@@ -28,13 +30,13 @@
 
 test -f /etc/default/postfix && . /etc/default/postfix
 
-test -x $DAEMON && test -f /etc/postfix/main.cf || exit 0
+test -x $DAEMON && test -f /etc/postfix2525/main.cf || exit 0
 
 . /lib/lsb/init-functions
 #DISTRO=$(lsb_release -is 2>/dev/null || echo Debian)
 
 running() {
-    queue=$(postconf -h queue_directory 2>/dev/null || echo /var/spool/postfix)
+    queue=$(postconf -c $CONFDIR -h queue_directory 2>/dev/null || echo /var/spool/postfix2525)
     if [ -f ${queue}/pid/master.pid ]; then
   pid=$(sed 's/ //g' ${queue}/pid/master.pid)
   # what directory does the executable live in.  stupid prelink systems.
@@ -66,7 +68,7 @@
       fi
 
       # see if anything is running chrooted.
-      NEED_CHROOT=$(awk '/^[0-9a-z]/ && ($5 ~ "[-yY]") { print "y"; exit}' /etc/postfix/master.cf)
+      NEED_CHROOT=$(awk '/^[0-9a-z]/ && ($5 ~ "[-yY]") { print "y"; exit}' /etc/postfix2525/master.cf)
 
       if [ -n "$NEED_CHROOT" ] && [ -n "$SYNC_CHROOT" ]; then
     # Make sure that the chroot environment is set up correctly.
@@ -111,7 +113,7 @@
     umask $oldumask
       fi
 
-      if start-stop-daemon --start --exec ${DAEMON} -- quiet-quick-start; then
+      if start-stop-daemon --start --exec ${DAEMON} -- ${DAEMON_OPTIONS} quiet-quick-start; then
     log_end_msg 0
       else
     log_end_msg 1
@@ -123,7 +125,7 @@
   RUNNING=$(running)
   log_daemon_msg "Stopping Postfix Mail Transport Agent" postfix
   if [ -n "$RUNNING" ]; then
-      if ${DAEMON} quiet-stop; then
+      if ${DAEMON} ${DAEMON_OPTIONS} quiet-stop; then
     log_end_msg 0
       else
     log_end_msg 1

If everything went well up to now you should be able to start your new postfix instance and check that it is actually running.

# /etc/init.d/postfix2525 start
# netstat -antp | grep 2525
tcp        0      0 127.0.0.1:2525          0.0.0.0:*               LISTEN      6138/master

2. Configure main postfix to accept emails from the intermediate
Edit /etc/postfix/master.cf and add this line at the bottom:

127.0.0.1:12525 inet n - - - - smtpd  -o smtp_fallback_relay= -o smtpd_client_restrictions=  -o smtpd_helo_restrictions=  -o smtpd_recipient_restrictions=permit_mynetworks,reject  -o smtpd_data_restrictions=  -o receive_override_options=no_unknown_recipient_checks

This defines a special port for the main postfix instance that has (or maybe it hasn’t actually) some special restrictions.
Actually you will have to change this line later on upon installing postfix-policyd, but this should be good enough for now, in order for you to do some testing.
Restart postfix

# /etc/init.d/postfix restart
# netstat -antp | grep 2525
tcp        0      0 127.0.0.1:12525         0.0.0.0:*               LISTEN      26799/master    
tcp        0      0 127.0.0.1:2525          0.0.0.0:*               LISTEN      6138/master   

The intermediate postfix listens on 127.0.0.1:2525 and the main one has another special listening port on 127.0.0.1:12525.

3. Test your intermediate postfix instance
You can do this in a gazillion different ways. One of my favorite ways to test SMTP connectivity is through telnet (—> shows data entry):

# telnet localhost 2525
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
220 server.mydomain.gr ESMTP Postfix
---> EHLO koko.gr
250-server.mydomain.gr
250-PIPELINING
250-SIZE 10240000
250-VRFY
250-ETRN
250-ENHANCEDSTATUSCODES
250-8BITMIME
250 DSN
---> MAIL FROM: lala@koko.gr
250 2.1.0 Ok
---> RCPT TO: koko@destination.gr
250 2.1.5 Ok
---> DATA
354 End data with <CR><LF>.<CR><LF>
---> THIS IS A TEST
---> .
250 2.0.0 Ok: queued as C41E21C84FF
---> quit

If you were keeping an eye on syslog messages you should have seen some connection messages both from postfix2525 and from postfix. If everything went well your email _should_ have arrived at it’s destination. If this is true then your primary postfix instance now works as a relay for your intermediate queue.

Don’t read the next parts of this post if you haven’t previously managed this step!

4. Install and configure postfix-policyd

# aptitude install postfix-policyd

To run policyd you need to create a database and import policyd SQL schema to it. Your distro has probably already taken care of the previous step, if it hasn’t…do it manually and think about changing distro!
Then edit the config file usually located at /etc/postfix-policyd.conf. The options I chose to play with were the following:
SENDERTHROTTLE=1
SENDER_THROTTLE_SASL=1
SENDER_THROTTLE_HOST=0

Since all emails will be relayed through localhost there’s no point in throttling per host, what is needed is throttling per envelope sender.
You should manually review your desired limits though. I won’t post mine here because everyone has different needs and there’s no sane config for everyone.

Start postfix-policyd
# /etc/init.d/postfix-policyd start

If you get weird startup errors like:
postfix-policyd: fatal: didn't find priority 'LOG_IFOO', exiting
Edit /etc/postfix-policyd.conf, find the following line:
SYSLOG_FACILITY="LOG_MAIL | LOG_INFO"
and change it to (mind the removed spaces):
SYSLOG_FACILITY="LOG_MAIL|LOG_INFO"

5. Configure main postfix instance to use postifix-policyd
Edit /etc/postfix/main.cf and add this:
webclient_restrictions = check_policy_service inet:127.0.0.1:10031

Then edit /etc/postfix/master.cf again and change the line you had previously added to the bottom of the file with this:

127.0.0.1:12525 inet n - - - - smtpd  -o smtp_fallback_relay= -o smtpd_client_restrictions=  -o smtpd_helo_restrictions=  -o smtpd_recipient_restrictions=permit_mynetworks,reject  -o smtpd_data_restrictions=  -o receive_override_options=no_unknown_recipient_checks -o smtpd_sender_restrictions=${webclient_restrictions}

The difference is
-o smtpd_sender_restrictions=${webclient_restrictions}
which practically instructs postfix to use postfix-policyd for emails that arrive on port 12525, which is the port that the intermediate postfix instance uses to relay all emails.

6. Test your intermediate postfix instance again
If everything went well, the main postfix instance should now be able to enforce sender policies. Try sending a new email through the intermediate postfix again, yes using telnet, and you should pickup some new log lines at your syslog:

Dec 19 21:56:40 myserver postfix-policyd: connection from: 127.0.0.1 port: 45635 slots: 0 of 4096 used
Dec 19 21:56:40 myserver postfix-policyd: rcpt=5, greylist=new, host=127.0.0.1 (unknown), from=lala@koko.gr, to=koko@lalala.gr, size=348
Dec 19 21:56:40 myserver postfix/smtpd[9168]: NOQUEUE: reject: RCPT from unknown[127.0.0.1]: 450 4.7.1 : Sender address rejected: Policy Rejection- Please try later.; from= to= proto=ESMTP helo=
Dec 19 21:56:40 myserver postfix/smtp[8970]: C41E21C84FF: to=, relay=127.0.0.1[127.0.0.1]:12525, delay=20, delays=20/0/0.01/0, dsn=4.7.1, status=deferred (host 127.0.0.1[127.0.0.1] said: 450 4.7.1 : Sender address rejected: Policy Rejection- Please try later. (in reply to RCPT TO command))

The above means that greylisting through policyd works.

7. make PHP use your new intermediate postfix instance
PHP on linux by default uses the sendmail binary to send emails via the mail() function. That would use the main postfix instance though, so one needs to edit /etc/php/apache2/php.ini and change the following line:
sendmail_path = "sendmail -C /etc/postfix2525 -t -i"

The -C directive instructs sendmail to use the alternate config dir, so that emails will be sent to the new intermediate postfix instance and then to the main one, passing through policyd of course.

To check the queue size of the intermediate postfix:
# postqueue -p -c /etc/postfix2525/

If any PHP applications that are hosted have explicit SMTP server/port directives, then be sure to notify your clients/developers that they _MUST_ use localhost:2525 to send their emails to and not the default localhost:25. This is one of the shortcomings of the above method, if someone manually sets up his application to use the default localhost:25 his emails will get right through. But being a good sysadmin, you should monitor such behavior and punish those users accordingly!

That’s about it…with the above configuration and some tweaking to the thresholds you have very good chances of avoiding getting blacklisted because someone decided to send a few thousand spams emails. And most importantly, your normal mail service will continue to work flawlessly, no matter how big the queue of the intermediate mail server is.

Enjoy!

Reference for policyd: http://policyd.sourceforge.net/readme.html

10 Responses to “Rate limit outgoing emails from PHP web applications using postfix and policyd”

  1. December 20th, 2011 | 12:57
    Using Mozilla Firefox Mozilla Firefox 3.0.19 on Windows Windows XP

    A very nice post, indeed web hosting is the primary constituent when you are starting a website. People rarely care about the web hosts but it is one of the most important parameter in the performance of website. I appreciate your post with such informative material. I just subscribed to your post and awaiting for more good posts on web hosting over the coming days. Thanks

  2. Stephen
    August 21st, 2012 | 21:03
    Using Mozilla Firefox Mozilla Firefox 14.0.1 on Windows Windows 7

    The biggest issue with policyd and php mail is that the normal mail function actually doesn’t go over SMTP. This completely bypasses the smtp restrictions in postfix and lets users run wild. Your solutions is a great one when the mailserver is on the same box as the webserver. Kudos.

  3. January 10th, 2013 | 13:24
    Using Mozilla Firefox Mozilla Firefox 18.0 on Ubuntu Linux Ubuntu Linux

    Hi,

    this is a very interesting post and I’m thinking about implementing something similar.
    One thing I noticed is that you have two init scripts and run two instances of postscript. I don’t think this would be necessary. You can just add an instance in master.cf on a different port (or IP) and override whatever setting is necessary (queue directory, etc) using -o switches. I use this for for before-queue-mail-filtering already.
    Anyway, wanted to ask whether after a while this works for you or whether you have found any issues with it.

    Thanks

  4. January 11th, 2013 | 12:42
    Using Debian IceWeasel Debian IceWeasel 18.0 on Linux Linux

    Hello Michael,

    you are probably right about the init scripts. It seemed a cleaner solution when I did it but probably I’ll change my configs to what you’re suggesting.

    After 1+ year in production this system works nicely and clients haven’t complained at all…so this makes it quite successful.

  5. January 23rd, 2013 | 14:54
    Using Mozilla Firefox Mozilla Firefox 18.0 on Ubuntu Linux Ubuntu Linux

    Interesting, I’m going to set this up soon too and will try to share if I come across anything that’s worth knowing.
    One other thing I wanted to ask: your article mentions briefly the policyd configuration and then you say:
    “Since all emails will be relayed through localhost there’s no point in throttling per host, what is needed is throttling per envelope sender.”
    Is that something you’ve managed to set up (throttling per envelope sender) (if not I was also considering more exotic ideas like traffic shaping using tc)?

  6. January 24th, 2013 | 00:07
    Using Debian IceWeasel Debian IceWeasel 18.0.1 on Linux Linux

    @Michael, yeah it’s easy to throttle per sender, just add a record to the ‘throttle’ table in the policyd database:

    
    INSERT INTO throttle (_from,_count_max,_quota_max,_time_limit,_mail_size,_date,_priority)
     VALUES ('foo@bar.com',  # from address                             
              100,              # maximum messages per time unit
              150000000,        # size in bytes (150 Mb) (maximum is 2Gb)
              3600,             # time unit in seconds (1 hour)
              1024000,          # maximum message size (10 Mb)
              UNIX_TIMESTAMP(), # current time
              10);              # priority of record
    

  7. February 20th, 2013 | 19:25
    Using Netscape Navigator Netscape Navigator 4.0

    [...] [...]

  8. Tom
    May 7th, 2013 | 07:17
    Using Mozilla Firefox Mozilla Firefox 20.0 on Ubuntu Linux Ubuntu Linux

    Hi There,

    I’ve been trying to find this workaround for a while by myself but didn’t get the policyd working as php would send out via sendmail / pickup, thank you so much for this howto. Please note that I’ve had to set

    alternate_config_directories = /etc/postfix2525

    in the main postfix config /etc/postfix/main.cf

    Cheers

  9. Dao Bao Ngoc
    October 10th, 2013 | 14:25
    Using Google Chrome Google Chrome 18.0.1025.168 on Linux Linux

    Hi,

    I’m facing with a very similar problem as you & I was very happy when I found this article.

    However, I could not manage to make my system work as expected.

    More details, I need to config & setup Mail spam filters for our system that uses Postfix & PolicyD. I have the same need as you before when I want to limit quota / filter spam… for emails come from mail() function.

    I follow all your steps with success tests as you described, but finally I could not get mails come from mail() go though PolicyD. These are even could not delivered to outside :(

    Errors I got as below:
    Oct 10 07:07:02 46-105-35-45 locally/pickup[8156]: 573CE24586: uid=1001 from=
    Oct 10 07:07:02 46-105-35-45 postfix/cleanup[10435]: 573CE24586: message-id=
    Oct 10 07:07:02 46-105-35-45 postfix/qmgr[2093]: 573CE24586: from=, size=432, nrcpt=1 (queue active)
    Oct 10 07:07:02 46-105-35-45 locally/pickup[8156]: 5884424585: uid=1001 from=
    Oct 10 07:07:02 46-105-35-45 postfix/cleanup[10435]: 5884424585: message-id=
    Oct 10 07:07:02 46-105-35-45 postfix/qmgr[2093]: 5884424585: from=, size=432, nrcpt=1 (queue active)
    Oct 10 07:07:02 46-105-35-45 postfix/error[10419]: 573CE24586: to=, relay=none, delay=0.01, delays=0.01/0/0/0, dsn=4.3.0, status=deferred (mail transport unavailable)
    Oct 10 07:07:02 46-105-35-45 postfix/error[10421]: 5884424585: to=, relay=none, delay=0, delays=0/0/0/0, dsn=4.3.0, status=deferred (mail transport unavailable)
    Oct 10 07:07:02 46-105-35-45 locally/pickup[8156]: 5B1272458A: uid=1001 from=
    Oct 10 07:07:02 46-105-35-45 postfix/cleanup[10435]: 5B1272458A: message-id=
    Oct 10 07:07:02 46-105-35-45 postfix/qmgr[2093]: 5B1272458A: from=, size=432, nrcpt=1 (queue active)
    Oct 10 07:07:02 46-105-35-45 postfix/error[10431]: 5B1272458A: to=, relay=none, delay=0.01, delays=0/0/0/0, dsn=4.3.0, status=deferred (mail transport unavailable)
    Oct 10 07:07:02 46-105-35-45 locally/pickup[8156]: 5DF7E2458C: uid=1001 from=
    Oct 10 07:07:02 46-105-35-45 postfix/cleanup[10435]: 5DF7E2458C: message-id=
    Oct 10 07:07:02 46-105-35-45 postfix/qmgr[2093]: 5DF7E2458C: from=, size=432, nrcpt=1 (queue active)
    Oct 10 07:07:02 46-105-35-45 postfix/error[10426]: 5DF7E2458C: to=, relay=none, delay=0.01, delays=0.01/0/0/0, dsn=4.3.0, status=deferred (mail transport unavailable)
    Oct 10 07:07:02 46-105-35-45 locally/pickup[8156]: 60EE92458E: uid=1001 from=
    Oct 10 07:07:02 46-105-35-45 postfix/cleanup[10435]: 60EE92458E: message-id=
    Oct 10 07:07:02 46-105-35-45 postfix/qmgr[2093]: 60EE92458E: from=, size=432, nrcpt=1 (queue active)
    Oct 10 07:07:02 46-105-35-45 postfix/error[10421]: 60EE92458E: to=, relay=none, delay=0.03, delays=0.01/0/0/0.02, dsn=4.3.0, status=deferred (mail transport unavailable)

    All config stuffs are very similar with yours:
    $ netstat -antp | grep 2525
    tcp 0 0 127.0.0.1:12525 0.0.0.0:* LISTEN 2089/master
    tcp 0 0 127.0.0.1:2525 0.0.0.0:* LISTEN 20507/master

    /etc/postfix2525/master.cf
    – The same as you gave

    /etc/postfix2525/main.cf
    – The same as you gave


    postconf -Mf
    smtp inet n – – – – smtpd
    -o smtpd_recipient_restrictions=$other_restrictions
    -o smtpd_sasl_auth_enable=yes
    pickup fifo n – – 60 1 pickup
    -o syslog_name=locally
    -o content_filter=$other_restrictions
    -o smtpd_sasl_auth_enable=yes
    cleanup unix n – – – 0 cleanup
    qmgr fifo n – n 300 1 qmgr
    tlsmgr unix – – – 1000? 1 tlsmgr
    rewrite unix – – – – – trivial-rewrite
    bounce unix – – – – 0 bounce
    defer unix – – – – 0 bounce
    trace unix – – – – 0 bounce
    verify unix – – – – 1 verify
    flush unix n – – 1000? 0 flush
    proxymap unix – – n – – proxymap
    proxywrite unix – – n – 1 proxymap
    smtp unix – – – – – smtp
    relay unix – – – – – smtp
    showq unix n – – – – showq
    error unix – – – – – error
    retry unix – – – – – error
    discard unix – – – – – discard
    local unix – n n – – local
    virtual unix – n n – – virtual
    lmtp unix – – – – – lmtp
    anvil unix – – – – 1 anvil
    scache unix – – – – 1 scache
    maildrop unix – n n – – pipe
    flags=DRhu user=vmail argv=/usr/bin/maildrop -d ${recipient}
    uucp unix – n n – – pipe
    flags=Fqhu user=uucp argv=uux -r -n -z -a$sender – $nexthop!rmail
    ($recipient)
    ifmail unix – n n – – pipe
    flags=F user=ftn argv=/usr/lib/ifmail/ifmail -r $nexthop ($recipient)
    bsmtp unix – n n – – pipe
    flags=Fq. user=bsmtp argv=/usr/lib/bsmtp/bsmtp -t$nexthop -f$sender
    $recipient
    scalemail-backend unix – n n – 2 pipe
    flags=R user=scalemail argv=/usr/lib/scalemail/bin/scalemail-store
    ${nexthop} ${user} ${extension}
    mailman unix – n n – – pipe
    flags=FR user=list argv=/usr/lib/mailman/bin/postfix-to-mailman.py
    ${nexthop} ${user}
    submission inet n – – – – smtpd
    -o smtpd_recipient_restrictions=$other_restrictions
    -o smtpd_sasl_auth_enable=yes
    127.0.0.1:12525 inet n – – – – smtpd
    -o smtp_fallback_relay=
    -o smtpd_client_restrictions=
    -o smtpd_helo_restrictions=
    -o smtpd_recipient_restrictions=permit_mynetworks,reject
    -o smtpd_data_restrictions=
    -o receive_override_options=no_unknown_recipient_checks
    -o smtpd_sender_restrictions=${webclient_restrictions}

    Any help would be greatly appreciated :)

  10. October 12th, 2013 | 14:46
    Using Debian IceWeasel Debian IceWeasel 22.0 on Linux Linux

    try and send an email from cli connecting to port 2525…Step 6. Does it work ? if not, you have to solve that problem first.

Leave a reply