Emailing Attachments: Exim Filters and PHP streams

In: PHP

31 May 2010

A while back a colleague and myself had a two week sprint to ensure that we could deliver 20k emails with up to 5mb attachments each. We experimented with 4k emails at 5mb each sending straight through the MTA, which we found to cause excessive load on the servers.

Using exim filters we could add attachments at the delivery stage, therefore reducing overhead in constructing and injecting data into an email. The delivery of emails varied as to whether it was feasible to send as a BCC or whether there were placeholders intended for each individual recipient. As such the following addresses the worst case scenario of attachments with placeholders intended for each individual recipient.

A few things that we needed to be careful of were:

  • Proper encoding and disposition of the content
  • _unique_ identifier used as placeholder
  • Ensure the MIME boundaries will still be unique after adding the payload.

One important fact is that if the exim filter failed for any reason, the message is retained in the queue, allowing you to control sending out broken messages, I.e. missing files.

Exims configuration is located within the following folders, the important part for this is the transport that specifies the exim_filter to be used. There are varying configurations of this, which you can use for smarthosts, local delivery or remote smtp.

/etc/exim4/conf.d/
|-- acl
|-- auth
|-- main
|-- retry
|-- rewrite
|-- router
`-- transport

Remote SMTP

### transport/30_exim4-config_remote_smtp
#################################
# This transport is used for delivering messages over SMTP connections.

remote_smtp:
  debug_print = "T: remote_smtp for $local_part@$domain"
  driver = smtp
  transport_filter = /path/to/exim/filter/attachments.php
.ifdef REMOTE_SMTP_HOSTS_AVOID_TLS
  hosts_avoid_tls = REMOTE_SMTP_HOSTS_AVOID_TLS
.endif
.ifdef REMOTE_SMTP_HEADERS_REWRITE
  headers_rewrite = REMOTE_SMTP_HEADERS_REWRITE
.endif
.ifdef REMOTE_SMTP_RETURN_PATH
  return_path = REMOTE_SMTP_RETURN_PATH
.endif
.ifdef REMOTE_SMTP_HELO_FROM_DNS
  helo_data=REMOTE_SMTP_HELO_DATA
.endif

Local Mail Delivery

### transport/30_exim4-config_maildir_home
#################################

# Use this instead of mail_spool if you want to to deliver to Maildir in
# home-directory - change the definition of LOCAL_DELIVERY
#
maildir_home:
  debug_print = "T: maildir_home for $local_part@$domain"
  driver = appendfile
  transport_filter = /path/to/exim/filter/attachments.php

  .ifdef MAILDIR_HOME_MAILDIR_LOCATION
  directory = MAILDIR_HOME_MAILDIR_LOCATION
  .else
  directory = $home/Maildir
  .endif
  .ifdef MAILDIR_HOME_CREATE_DIRECTORY
  create_directory
  .endif
  .ifdef MAILDIR_HOME_CREATE_FILE
  create_file = MAILDIR_HOME_CREATE_FILE
  .endif
  delivery_date_add
  envelope_to_add
  return_path_add
  maildir_format
  .ifdef MAILDIR_HOME_DIRECTORY_MODE
  directory_mode = MAILDIR_HOME_DIRECTORY_MODE
  .else
  directory_mode = 0700
  .endif
  .ifdef MAILDIR_HOME_MODE
  mode = MAILDIR_HOME_MODE
  .else
  mode = 0600
  .endif
  mode_fail_narrower = false
  # This transport always chdirs to $home before trying to deliver. If
  # $home is not accessible, this chdir fails and prevents delivery.
  # If you are in a setup where home directories might not be
  # accessible, uncomment the current_directory line below.
  # current_directory = /

Remote Smarthost

### transport/30_exim4-config_remote_smtp_smarthost
#################################

# This transport is used for delivering messages over SMTP connections
# to a smarthost. The local host tries to authenticate.
# This transport is used for smarthost and satellite configurations.

remote_smtp_smarthost:
  debug_print = "T: remote_smtp_smarthost for $local_part@$domain"
  driver = smtp
  transport_filter = /path/to/exim/filter/attachments.php
  hosts_try_auth = < ; ${if exists{CONFDIR/passwd.client}
        {
        ${lookup{$host}nwildlsearch{CONFDIR/passwd.client}{$host_address}}
        }
        {}
      }
.ifdef REMOTE_SMTP_SMARTHOST_HOSTS_AVOID_TLS
  hosts_avoid_tls = REMOTE_SMTP_SMARTHOST_HOSTS_AVOID_TLS
.endif
.ifdef REMOTE_SMTP_HEADERS_REWRITE
  headers_rewrite = REMOTE_SMTP_HEADERS_REWRITE
.endif
.ifdef REMOTE_SMTP_RETURN_PATH
  return_path = REMOTE_SMTP_RETURN_PATH
.endif
.ifdef REMOTE_SMTP_HELO_FROM_DNS
  helo_data=REMOTE_SMTP_HELO_DATA
.endif

An example of an exim filter

abstract class eximFilter extends php_user_filter {

  private $_stream;
  private $_filterAppended  = FALSE;
  private $_replacements = array();

  private function flush() {
    rewind($this->_stream);
    fpassthru($this->_stream);
    fclose($this->_stream);
  }

  /**
   * Ensure output steam available
   */
  private function hasStream() {
    return is_resource($this->_stream);
  }

  private function addFilter() {

  }

  /**
   * Remove last attached stream
   */
  private function removeFilter() {
    if ($this->hasStream())
    $this->_filterAppended =  !stream_filter_remove($this->_stdOut);
  }

  public function __destruct() {
    if (is_resource($this->_stream))  fclose($this->_stream);
  }

  /**
   * @param string $reason
   */
  protected function bail($reason = false) {
    if(is_string($reason)) {
      echo $reason;
    }
    exit(1);

  }

}

class stdOutFilter extends eximFilter {
  public function filter($line) {
    echo $line;
  }
}

class eximFilters {

  private $_data;
  private $_wrapper = 'exim';
  public  $_filters = array();
  private $_filterWrapper, $_filterName;

  private $_instream;
  private $_outstream;

  public function setStreams($in, $out) {
    $this->_instream = $in;
    $this->_outstream = $out;
    return $this;
  }

  public function addFilter(eximFilter $filter) {
    $this->_filters[ $f = get_class($filter) ] = $filter;
    return $this;
  }

  public function removeFilter(eximFilter $filter) {
    unset( $this->_filters[ get_class($filter) ]);
    return $this;
  }

  public function getFilters() {
    return $this->_filters;
  }

  /**
   * Ensure streams available
   */
  private function hasStream() {
    return (is_resource($this->_instream) && is_resource($this->_outstream));
  }

  public function execute() {
    while($line = fgets($this->_instream)) {
      foreach($this->getFilters() as $filter) {
        $filter->filter($line);
      }
    }
  }
}

$filters = new eximFilters();
$filters->setStreams( fopen('php://stdin',  'r+'), fopen('php://stdout',  'w') )
->addFilter(new stdOutFilter())
->execute();

With attachments you have to base64 encode the stream and split the data at the 76 character onto a new line. You also have to have a scheme for acquiring the header and location for the file that is to be injected into the stream and remove the line.

This reduced IO, Memory and CPU usage when dispatching bulk emails with attachments with roughly a 2000% performance increase.

Comment Form

About this blog

I have been a developer for roughly 10 years and have worked with an extensive range of technologies. Whilst working for relatively small companies, I have worked with all aspects of the development life cycle, which has given me a broad and in-depth experience.