Send authenticated SMTP email over TLS from WordPress

Learn how to override wp-mail() and send secure email using authenticated SMTP and StartTLS from WordPress.
Published on Tuesday, 29 July 2014

I was suprised WordPress is not able to send email using an SMTP server out-of-the-box. Not to mention using TLS transport for security. A quick Google search showed me multiple plugins to handle this. Hence, everything is handled through plugins in WordPress... Need to optimize your website? Use plugin x. Want a more secure WordPress? Use plugin y. I wanted to create something myself and here is how to override the wp-mail() function and send email using authenticated SMTP and StartTLS from WordPress.


Authenticated SMTP and TLS in WordPress - secure email

I haven't checked how other plugins work, but I was sure that I wouldn't want my SMTP credentials to be stored in the MySQL database. My thought was that storing the SMTP credentials in the wp-config.php file might be better.

I decided to try something, and it turns out to be pretty easy! Just follow the next few steps and you'll send emails from WordPress using authenticated SMTP (SMTP AUTH) over a StartTLS/TLS secured connection.

The mail sending function, called wp-mail, is defined in the file pluggable.php. This file is located in the directory /wp-includes. This means we can overrule it with our own function and plugin. To start, just copy that function into a new file. Now locate $phpmailer->IsMail(); and change that to $phpmailer->IsSMTP();.

Later on you have to place your PHPMailer configuration directly below this line. If you'd like more information on some PHPMailer configuration settings, see https://github.com/PHPMailer/PHPMailer/blob/master/README.md, and/or the PHP code found in Part 2 below.

As shown below, you can define your SMTP configuration values in your wp-config.php file. If placed in the plugin file, there is always that possibility of a registered user being able to view the contents of that file. We don't want that. Now, let's go.

WordPress SMTP configuration in wp-config.php

We don't want to store our SMTP credentials in either the MySQL database (wp_options table) or wp-mail.php plugin file. Therefor we need to define it in wp-config.php using PHP's mail() function.

In /wp-config.php, simply add:

define( 'SMTP_USER', 'user@example.com' );
define( 'SMTP_PASS', 'Your p4ssword' );
define( 'SMTP_PORT', '25' );
define( 'SMTP_SECURE', 'tls' );
define( 'SMTP_AUTH', true );
define( 'SMTP_HOST', 'smtp.example.com' );
define( 'SMTP_FROM', 'website@example.com' );
define( 'SMTP_FROM_NAME', 'e.g Website Name' );
define( 'SMTP_DEBUG', 0 ); // for debugging purposes only set to 1 or 2

WordPress wp-mail.php plugin file for SMTP

The second part is this wp-mail.php file.

File: /wp-content/plugins/wp-mail/wp-mail.php
Copy and paste the wp_mail() function from /wp-includes/pluggable.php, or copy and paste the code below and put it in a new file.

Save that new file as wp-mail.php.

Now create a new folder called wp-mail in your /wp-content/plugins directory, for instance using FTP, and upload your newly created wp-mail.php file to that location.

<?php
/**
 * Send authenticated SMTP email over TLS with WordPress
 *
 * @wordpress-plugin
 * Plugin Name: Authenticated SMTP email over TLS with WordPress
 * Plugin URI: https://www.saotn.org
 * Donate Link: https://www.paypal.me/jreilink
 * Description: Sends email using authenticated SMTP (SMTP AUTH), over an TLS encrypted connection in WordPress.
 * Version: 1.0.0
 * Author: Jan Reilink
 * Author URI: https://www.saotn.org
 * License: GPL-2.0+
 * License URI: http://www.gnu.org/licenses/gpl-2.0.txt
 */

/**
 * Plugin to override the wp_mail function in pluggable.php. See
 * pluggable.php for details.
 */
if (!function_exists('wp_mail')) {
  add_filter('plugin_row_meta', 'plugin_row_meta', 10, 2);
  function plugin_row_meta($links, $file) {
    if ( !preg_match('/wp-saotn-mail.php$/', $file ) ) {
      return $links;
    }
        
    $links[] = sprintf(
      '<a href="https://www.paypal.me/jreilink">%s</a>',
      __( 'Donate' )
    );
    return $links;
  }

  function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
    // Compact the input, apply the filters, and extract them back out

    /**
     * Filter the wp_mail() arguments.
     *
     * @since 2.2.0
     *
     * @param array $args A compacted array of wp_mail() arguments, including the "to" email,
     *                    subject, message, headers, and attachments values.
     */
    extract( apply_filters( 'wp_mail', compact( 'to', 'subject', 'message', 'headers', 'attachments' ) ) );

    if ( !is_array($attachments) )
      $attachments = explode( "\n", str_replace( "\r\n", "\n", $attachments ) );

    global $phpmailer;

    // (Re)create it, if it's gone missing
    if ( !is_object( $phpmailer ) || !is_a( $phpmailer, 'PHPMailer' ) ) {
      require_once ABSPATH . WPINC . '/class-phpmailer.php';
      require_once ABSPATH . WPINC . '/class-smtp.php';
      $phpmailer = new PHPMailer( true );
    }

    // Headers
    if ( empty( $headers ) ) {
      $headers = array();
    } else {
      if ( !is_array( $headers ) ) {
        // Explode the headers out, so this function can take both
        // string headers and an array of headers.
        $tempheaders = explode( "\n", str_replace( "\r\n", "\n", $headers ) );
      } else {
        $tempheaders = $headers;
      }
      $headers = array();
      $cc = array();
      $bcc = array();

      // If it's actually got contents
      if ( !empty( $tempheaders ) ) {
        // Iterate through the raw headers
        foreach ( (array) $tempheaders as $header ) {
          if ( strpos($header, ':') === false ) {
            if ( false !== stripos( $header, 'boundary=' ) ) {
              $parts = preg_split('/boundary=/i', trim( $header ) );
              $boundary = trim( str_replace( array( "'", '"' ), '', $parts[1] ) );
            }
            continue;
          }
          // Explode them out
          list( $name, $content ) = explode( ':', trim( $header ), 2 );

          // Cleanup crew
          $name    = trim( $name    );
          $content = trim( $content );

          switch ( strtolower( $name ) ) {
            // Mainly for legacy -- process a From: header if it's there
            case 'from':
              if ( strpos($content, '<' ) !== false ) {
                // So... making my life hard again?
                $from_name = substr( $content, 0, strpos( $content, '<' ) - 1 );
                $from_name = str_replace( '"', '', $from_name );
                $from_name = trim( $from_name );

                $from_email = substr( $content, strpos( $content, '<' ) + 1 );
                $from_email = str_replace( '>', '', $from_email );
                $from_email = trim( $from_email );
              } else {
                $from_email = trim( $content );
              }
              break;
            case 'content-type':
              if ( strpos( $content, ';' ) !== false ) {
                list( $type, $charset ) = explode( ';', $content );
                $content_type = trim( $type );
                if ( false !== stripos( $charset, 'charset=' ) ) {
                  $charset = trim( str_replace( array( 'charset=', '"' ), '', $charset ) );
                } elseif ( false !== stripos( $charset, 'boundary=' ) ) {
                  $boundary = trim( str_replace( array( 'BOUNDARY=', 'boundary=', '"' ), '', $charset ) );
                  $charset = '';
                }
              } else {
                $content_type = trim( $content );
              }
              break;
            case 'cc':
              $cc = array_merge( (array) $cc, explode( ',', $content ) );
              break;
            case 'bcc':
              $bcc = array_merge( (array) $bcc, explode( ',', $content ) );
              break;
            default:
              // Add it to our grand headers array
              $headers[trim( $name )] = trim( $content );
              break;
          }
        }
      }
    }

    // Empty out the values that may be set
    $phpmailer->ClearAllRecipients();
    $phpmailer->ClearAttachments();
    $phpmailer->ClearCustomHeaders();
    $phpmailer->ClearReplyTos();

    // From email and name
    // If we don't have a name from the input headers
    if ( !isset( $from_name ) )
      $from_name = 'WordPress';

    /* If we don't have an email from the input headers default to wordpress@$sitename
     * Some hosts will block outgoing mail from this address if it doesn't exist but
     * there's no easy alternative. Defaulting to admin_email might appear to be another
     * option but some hosts may refuse to relay mail from an unknown domain. See
     * http://trac.wordpress.org/ticket/5007.
     */

    if ( !isset( $from_email ) ) {
      // Get the site domain and get rid of www.
      $sitename = strtolower( $_SERVER['SERVER_NAME'] );
      if ( substr( $sitename, 0, 4 ) == 'www.' ) {
        $sitename = substr( $sitename, 4 );
      }

      $from_email = 'wordpress@' . $sitename;
    }

    /**
     * Filter the email address to send from.
     *
     * @since 2.2.0
     *
     * @param string $from_email Email address to send from.
     */
    $phpmailer->From = apply_filters( 'wp_mail_from', $from_email );

    /**
     * Filter the name to associate with the "from" email address.
     *
     * @since 2.3.0
     *
     * @param string $from_name Name associated with the "from" email address.
     */
    $phpmailer->FromName = apply_filters( 'wp_mail_from_name', $from_name );

    // Set destination addresses
    if ( !is_array( $to ) )
      $to = explode( ',', $to );

    foreach ( (array) $to as $recipient ) {
      try {
        // Break $recipient into name and address parts if in the format "Foo <bar@baz.com>"
        $recipient_name = '';
        if( preg_match( '/(.*)<(.+)>/', $recipient, $matches ) ) {
          if ( count( $matches ) == 3 ) {
            $recipient_name = $matches[1];
            $recipient = $matches[2];
          }
        }
        $phpmailer->AddAddress( $recipient, $recipient_name);
      } catch ( phpmailerException $e ) {
        continue;
      }
    }

    // Set mail's subject and body
    $phpmailer->Subject = $subject;
    $phpmailer->Body    = $message;

    // Add any CC and BCC recipients
    if ( !empty( $cc ) ) {
      foreach ( (array) $cc as $recipient ) {
        try {
          // Break $recipient into name and address parts if in the format "Foo <bar@baz.com>"
          $recipient_name = '';
          if( preg_match( '/(.*)<(.+)>/', $recipient, $matches ) ) {
            if ( count( $matches ) == 3 ) {
              $recipient_name = $matches[1];
              $recipient = $matches[2];
            }
          }
          $phpmailer->AddCc( $recipient, $recipient_name );
        } catch ( phpmailerException $e ) {
          continue;
        }
      }
    }

    if ( !empty( $bcc ) ) {
      foreach ( (array) $bcc as $recipient) {
        try {
          // Break $recipient into name and address parts if in the format "Foo <bar@baz.com>"
          $recipient_name = '';
          if( preg_match( '/(.*)<(.+)>/', $recipient, $matches ) ) {
            if ( count( $matches ) == 3 ) {
              $recipient_name = $matches[1];
              $recipient = $matches[2];
            }
          }
          $phpmailer->AddBcc( $recipient, $recipient_name );
        } catch ( phpmailerException $e ) {
          continue;
        }
      }
    }

    // Everything's defined in wp-config.php
    // Remove what you don't need, see https://github.com/PHPMailer/PHPMailer/blob/master/README.md
    $phpmailer->IsSMTP();
    $phpmailer->SMTPSecure = SMTP_SECURE;    // set security schema
    $phpmailer->SMTPAuth   = SMTP_AUTH;      // enable SMTP authentication
    $phpmailer->Port       = SMTP_PORT;      // set the SMTP server port
    $phpmailer->Host       = SMTP_HOST;      // SMTP server
    $phpmailer->Username   = SMTP_USER;      // SMTP server username
    $phpmailer->Password   = SMTP_PASS;      // SMTP server password
    $phpmailer->From       = SMTP_FROM;      // SMTP From email address
    $phpmailer->FromName   = SMTP_FROM_NAME; // SMTP From name
    $phpmailer->SMTPDebug = SMTP_DEBUG;

    // Set Content-Type and charset
    // If we don't have a content-type from the input headers
    if ( !isset( $content_type ) )
      $content_type = 'text/plain';

    /**
     * Filter the wp_mail() content type.
     *
     * @since 2.3.0
     *
     * @param string $content_type Default wp_mail() content type.
     */
    $content_type = apply_filters( 'wp_mail_content_type', $content_type );

    $phpmailer->ContentType = $content_type;

    // Set whether it's plaintext, depending on $content_type
    if ( 'text/html' == $content_type )
      $phpmailer->IsHTML( true );

    // If we don't have a charset from the input headers
    if ( !isset( $charset ) )
      $charset = get_bloginfo( 'charset' );

    // Set the content-type and charset

    /**
     * Filter the default wp_mail() charset.
     *
     * @since 2.3.0
     *
     * @param string $charset Default email charset.
     */
    $phpmailer->CharSet = apply_filters( 'wp_mail_charset', $charset );

    // Set custom headers
    if ( !empty( $headers ) ) {
      foreach( (array) $headers as $name => $content ) {
        $phpmailer->AddCustomHeader( sprintf( '%1$s: %2$s', $name, $content ) );
      }

      if ( false !== stripos( $content_type, 'multipart' ) && ! empty($boundary) )
        $phpmailer->AddCustomHeader( sprintf( "Content-Type: %s;\n\t boundary=\"%s\"", $content_type, $boundary ) );
    }

    if ( !empty( $attachments ) ) {
      foreach ( $attachments as $attachment ) {
        try {
          $phpmailer->AddAttachment($attachment);
        } catch ( phpmailerException $e ) {
          continue;
        }
      }
    }

    /**
     * Fires after PHPMailer is initialized.
     *
     * @since 2.2.0
     *
     * @param PHPMailer &$phpmailer The PHPMailer instance, passed by reference.
     */
    do_action_ref_array( 'phpmailer_init', array( &$phpmailer ) );

    // Send!
    try {
      return $phpmailer->Send();
    } catch ( phpmailerException $e ) {
      return false;
    }
  }
}
?>

Try for yourself now: activate the plugin, launch a new and different browser and register yourself as a new user on your blog. If you look at the headers of the confirmation email you received, you'll see the relevant SMTP and StartTLS lines.

For example:

[...]
Received: from www.saotn.org (unknown [IPv6:2a00:f60::2:153])
    (using TLSv1 with cipher DHE-RSA-AES256-SHA (256/256 bits))
    (No client certificate requested)
    (Authenticated sender: user@example.com)
    by some_host.example.org (Postfix) with ESMTPSA id E9E74F80034
    for <some_user@example.org>; Tue, 29 Jul 2014 16:05:30 +0200 (CEST)
Date: Tue, 29 Jul 2014 14:05:30 +0000
To: some_user@example.org
From: Sysadmins of the North <website@saotn.org>
Reply-To: Jan Reilink <jan@saotn.nl>
Subject: [Sysadmins of the North] Your username and password
Message-ID: <a155ef9bd228bd89330da98f1e0391bd@www.saotn.org>
X-Priority: 3
X-Mailer: PHPMailer 5.2.7 (https://github.com/PHPMailer/PHPMailer/)

Send email in WordPress using Google Gmail SMTP servers

Relay WordPress email through Gmail SMTP

Want to send email in WordPress using Google Gmail SMTP servers? Here's how to send email in WordPress using Gmail. Use the following SMTP configuration in your wp-config.php file. You have to use SSL as SMTP_SECURE option.

define( 'SMTP_USER', 'user@gmail.com' );
define( 'SMTP_PASS', 'p4ssword' );
define( 'SMTP_PORT', '465' );
define( 'SMTP_SECURE', 'ssl' );
define( 'SMTP_AUTH', true );
define( 'SMTP_HOST', 'smtp.gmail.com' );
define( 'SMTP_DEBUG', 0 ); // for debugging purposes only set to 1 or 2

Now WordPress sends email through smtp.gmail.com as a relay.

WordPress enhancement proposal

I decided to send my WordPress enhancement to the WordPress developers trough WordPress Trac. Maybe it's a fine addition to the WordPress core. You can find my enhancement proposal here: Proposal: wp-pluggable.php patch to send email through SMTP, not mail().

wp-includes/pluggable.php patch

--- wp-includes\pluggable.orig.php      Wed Jul 30 11:52:55 2014
+++ wp-includes\pluggable.php   Wed Jul 30 11:48:25 2014
@@ -433,7 +433,24 @@
        }
 
        // Set to use PHP's mail()
- $phpmailer->IsMail();
+       if ( ! USE_SMTP ) {
+               $phpmailer->IsMail();
+       }
+       else {
+               $phpmailer->IsSMTP();
+               $phpmailer->SMTPSecure = SMTP_SECURE;    // set security schema, tls or ssl
+               $phpmailer->SMTPAuth   = SMTP_AUTH;      // enable SMTP authentication
+               $phpmailer->Port       = SMTP_PORT;      // set the SMTP server port, 25 or 587
+               $phpmailer->Host       = SMTP_HOST;      // SMTP server
+               $phpmailer->Username   = SMTP_USER;      // SMTP server username
+               $phpmailer->Password   = SMTP_PASS;      // SMTP server password
+               $phpmailer->From       = SMTP_FROM;      // SMTP From email address
+               $phpmailer->FromName   = SMTP_FROM_NAME; // SMTP From name
+               if ( SMTP_ADD_REPLYTO_EMAIL !== ''  && SMTP_ADD_REPLYTO_NAME !== '' ) {
+                       $phpmailer->AddReplyTo(SMTP_ADD_REPLYTO_EMAIL, SMTP_ADD_REPLYTO_NAME);
+               }
+               $phpmailer->SMTPDebug = SMTP_DEBUG;      // debug level, 1, 2 or 0
+       }
 
        // Set Content-Type and charset
        // If we don't have a content-type from the input headers

wp-config.php patch

--- wp-config.orig.php  Wed Jul 30 12:05:57 2014
+++ wp-config.php       Wed Jul 30 12:08:18 2014
@@ -58,9 +58,17 @@
 
 /**#@-*/
 
-if(!defined('WP_WINCACHE_KEY_SALT')) {
- define('WP_WINCACHE_KEY_SALT', 'wp_2389x#s_');
-}
+define('SMTP_USER', 'user@example.com');
+define('SMTP_PASS', 'p4ssword');
+define('SMTP_PORT', '25');
+define('SMTP_SECURE', 'tls');
+define('SMTP_AUTH', true);
+define('SMTP_HOST', 'smtp.example.com');
+define('SMTP_FROM', 'website@example.com');
+define('SMTP_FROM_NAME', 'e.g Website Name');
+define('SMTP_ADD_REPLYTO_NAME', 'FirstName LastName');
+define('SMTP_ADD_REPLYTO_EMAIL', 'userName@example.org');
+define('SMTP_DEBUG', 0); // for debugging purposes only set to 1 or 2
 
 /**
  * WordPress Database Table prefix.

Note: the WP_WINCACHE_KEY_SALT is specific to my set up.