snicco/better-wp-mail

Keep your sanity when working with mails in WordPress

v1.9.0 2023-09-20 12:36 UTC

README

codecov Psalm Type-Coverage Psalm level PhpMetrics - Static Analysis PHP-Versions

BetterWPMail is a small library that provides an expressive, object-orientated API around the wp_mail function.

BetterWPMail is not an SMTP-plugin!

It has (optional) support for many mail transports, but will default to using a WPMailTransport, so that it's usable in distributed WordPress code.

Table of contents

  1. Motivation
  2. Installation
  3. Creating a mailer
  4. Creating and sending emails
    1. Immutability
    2. Sending an email
    3. Adding addresses
    4. Setting mail content
    5. Adding context to templates
    6. Adding attachments
    7. Embedding images
    8. Adding custom headers
    9. Configuring emails globally
    10. Extending the email class
    11. Using mail events
    12. Writing emails in markdown / Using a custom renderer
    13. Handling exceptions
  5. Testing
  6. Contributing
  7. Issues and PR's
  8. Security

Motivation

To list all problems of the wp_mail function would take a long time. The most problematic ones are:

  • ❌ No support for a plain-text version when sending a html body.
  • ❌ No support for inline-attachments.
  • ❌ No support for complex multi-part emails.
  • ❌ You can't choose a custom filename for attachments.
  • ❌ You can't send attachments that you already have in memory (like a generated PDF). You always have to write to a tmp file first.
  • ❌ Zero error-handling.
  • ❌ No support for templated emails.
  • ...
  • ...

Many plugins employ massive hacks to circumvent these issues:

This is what you probably find in most WordPress plugin code:

function my_plugin_send_mail(string $to, string $html_message) {
    
    add_filter('phpmailer_init', 'add_plain_text');
    
    /* Add ten other filters */
    wp_mail($to, $html_message);
    
    remove_filter('phpmailer_init', 'add_plain_text')
    
    /* Remove ten other filters */
}

function add_plain_text(\PHPMailer\PHPMailer\PHPMailer $mailer) {
    $mailer->AltBody = strip_tags($mailer->Body);
}

Why is this so bad?

Besides the fact that you are running a lot of unneeded hooks for every email you sent, what happens if wp_mail throws an exception that is recovered somewhere else?

You now have ten leftover hook callbacks that modify every outgoing email during the same PHP process. Depending on the kind of filters you added, there is now great potential for bugs that are almost impossible to debug.

A real example of this can be seen here in the WooCommerce code base. (Not bashing WooCommerce here, there is currently no alternative with the way wp_mail works.)

Under the hood WordPress uses the bundled PHPMailer which is a reputable and rock-solid library. PHPMailer has native support for most of the problems listed above, wp_mail just doesn't use them.

Here is where BetterWPMail comes into play.

Installation

composer require snicco/better-wp-mail

Creating a Mailer

Instead of using wp_mail directly, you'll use the Mailer class which is able to send Email objects.

Quickstart:

use Snicco\Component\BetterWPMail\Mailer;

$mailer = new Mailer();

The full signature of Mailer::__construct is

  public function __construct(
        ?Transport $transport = null,
        ?MailRenderer $mail_renderer = null,
        ?MailEvents $event_dispatcher = null,
        ?MailDefaults $default_config = null
    )
  • Transport is an interface and will default to the WPMailTransport where all emails will eventually send using wp_mail.

    If you are using BetterWPMail in a controlled environment, you can provide your own implementation of the Transport interface. If you are distributing code you should always use the default transport since you can't control the SMTP-Plugin that your users will have installed.

    In the future we will create a symfony/mailer transport which will allow you to send emails with any of dozens of providers that Symfony`s mailer integrates with.

  • The MailRenderer interface is responsible for converting mail templates to html/plain-text content. By default, a FileSystemRenderer will be used, which searches for a file matching the template name.

  • The MailEvents interface is responsible for firing events right before and right after an email was sent. By default, an instance of NullEvents will be used which will not emit any events.

  • MailDefaults is responsible for providing fallback configuration for settings sender name, reply-to address etc.

Creating and sending emails

Immutability

The Email class is an immutable value object. You can not change an email once its created. All public methods on the Email class return a new, modified version of the object.

Immutability is not common in the PHP community, but it's actually simple to understand:

use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = new Email();

❌ // This is incorrect.
$email->addTo('calvin@snicco.io');

✅ // This is correct
$email = $email->addTo('calvin@snicco.io');

The basic convention in BetterWPMail is:

  • methods starting with add will merge attributes and return a new object.
  • methods starting with with will replace attributes and return a new object.
use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = new Email();

$email = $email->addTo('calvin@snicco.io');
// The email has one recipient now.

$email = $email->addTo('marlon@snicco.io');
// The email has two recipients now.

$email = $email->withTo('jondoe@snicco.io');
// The email has one recipient "jondoe@snicco.io"

Sending an email

Emails are sent using the Mailer class.

At minimum, an email needs a recipient and a body (html/text/attachments):

use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())->addTo('calvin@snicco.io')
                      ->withHtmlBody('<h1>BetterWPMail is awesome</h1>');

$mailer->send($email);

Adding addresses

All the methods that require email addresses (from(), to(), etc.) accept strings, arrays, a WP_User instance or a MailBox instance

use Snicco\Component\BetterWPMail\ValueObject\Email;
use Snicco\Component\BetterWPMail\ValueObject\Mailbox;

$email = new Email();

$admin = new WP_User(1);

$email = $email
    
    // email address is a simple string
    ->addTo('calvin@snicco.io')
    
    // with an explicit display name
    ->addCc('Marlon <marlon@snicco.io>')
    
    // as an array, where the first argument is the email
    ->addBcc(['Jon Doe', 'jon@snicco.io'])        
    
    // as an array with a "name" + "email" key        
    ->addFrom(['name' => 'Jane Doe', 'email' => 'jane@snicco.io'])

    // with an instance of WP_USER
    ->addFrom($admin)        
    
    // with an instance of MailBox
    ->addReplyTo(Mailbox::create('no-reply@snicco.io'));

Setting mail content

You have two options for setting the content of an email:

  1. By setting it explicitly as a string.
  2. By setting a template on the email object which will be rendered to html/plain-text before sending.
use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())->addTo('calvin@snicco.io');

$email = $email
    ->withHtmlBody('<h1>BetterWPMail is awesome</h1>')
    ->withTextBody('BetterWPMail supports plain text.')

$templated_email = $email
    ->withHtmlTemplate('/path/to/template-html.php')
    ->withTextBody('/path/to/template-plain.txt')

If an email has html-content but no explicit text-content, then the html-content will be passed through strip_tags and be used as the plain-text version.

Adding context to templates

Assuming that we want to send a welcome email to multiple users with the following template:

<?php
// path/to/email-templates/welcome.php
?>
<h1>Hi <?= esc_html($first_name) ?></h1>,

<p>Thanks for signing up to <?= esc_html($site_name) ?></p>

Here we can use the fact that emails are immutable to reuse a base email instance:

use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())

    ->withHtmlTemplate('path/to/email-templates/welcome.php')
    
    ->withContext(['site_name' => 'snicco.io']);

// Important: don't use withContext here or site_name is gone.
$email1 = $email->addContext('first_name', 'Calvin')
                ->addTo('calvin@snicco.io');
                
$mailer->send($email1);

$email2 = $email->addContext('first_name', 'Marlon');
                ->addTo('marlon@snicco.io');
                
$mailer->send($email2);

This will result in the following two emails being sent:

<h1>Hi Calvin</h1>,

<p>Thanks for signing up to snicco.io</p>
<h1>Hi Marlon</h1>,

<p>Thanks for signing up to snicco.io</p>

Adding attachments

Attachments can be added to an instance of Email in two ways:

  1. Attaching a local path on the filesystem.
use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())->addTo('calvin@snicco.io');

$email = $email

    ->addAttachment('/path/to/documents/terms-of-use.pdf')
    
    // optionally with a custom display name
    ->addAttachment('/path/to/documents/privacy.pdf', 'Privacy Policy')
    
    // optionally with an explicit content-type,
    ->addAttachment('/path/to/documents/contract.doc', 'Contract', 'application/msword');
  1. Attaching a binary string or a stream that you already have in memory (a generated PDF for example)
use Snicco\Component\BetterWPMail\ValueObject\Email;

$pdf = /* generate pdf */

$email = (new Email())->addTo('calvin@snicco.io');

$email = $email

    ->addBinaryAttachment($pdf, 'Your PDF', 'application/pdf')

BetterWPMail depends on it's Transport interface to perform the actual sending of emails. For that reason, no mime-type detection is performed if you don't pass an explicit content-type for an attachment. This is delegated to the concrete transport implementation.

The WPMailTransport will delegate this task to wp_mail/PHPMailer. The behaviour of PHPMailer is the following:

  1. If you pass an explicit mime-type, use that.
  2. Try to guess the mime-type from the filename.
  3. If 2. is not possible default to application/octet-stream which is defined as "arbitrary binary data".

Embedding Images

If you want to display images inside your email, you must embed them instead of adding them as attachments.

In your email content you can then reference the embedded image with the syntax: cid: + image embed name

<?php
// path/to/email-templates/welcome-with-image.php
?>
<h1>Hi <?= esc_html($first_name) ?></h1>,

<img src="cid:logo">
  use Snicco\Component\BetterWPMail\ValueObject\Email;
  
  $email = (new Email())
    ->addTo('calvin@snicco.io')
    ->addContext('first_name', 'Calvin');
  
  $email1 = $email
      ->addEmbed('/path/to/images/logo.png', 'logo', 'image/png')
      ->withHtmlTemplate('path/to/email-templates/welcome-with-image.php');
  
  // or with inline html
  $email2 = $email
      ->addEmbed('/path/to/images/logo.png', 'logo', 'image/png')
      ->withHtmlBody('<img src="cid:logo">');  

Adding custom headers

  use Snicco\Component\BetterWPMail\ValueObject\Email;
  
  $email = (new Email())
    ->addTo('calvin@snicco.io')
    // custom headers are string, string key value pairs.
    // These are not validated in any form.
    ->addCustomHeaders(['X-Auto-Response-Suppress'=> 'OOF, DR, RN, NRN, AutoReply'])

Configuring emails globally

The default configuration for all your emails is determined by the MailDefaults class that you pass into the Mailer class.

If you don't explicitly pass an instance of MailDefaults when creating your Mailer, they will be created based on the global WordPress settings.

Remember: You can always overwrite these settings on a per-email basis.

use Snicco\Component\BetterWPMail\Mailer;
use Snicco\Component\BetterWPMail\ValueObject\MailDefaults;

$from_name = 'My Plugin';
$from_email = 'myplugin@site.com';

$reply_to_name = 'My Plugin Reply-To'
$reply_to_email = 'myplugin-reply-to@site.com';

$mail_defaults = new MailDefaults(
    $from_name,
     $from_email, 
     $reply_to_name, 
     $reply_to_email
);

// Other arguments set to default for brevity.
$mailer = new Mailer(null, null, null, $mail_defaults);

Extending the Email class

If you are sending the same email in multiple places, you might want to extend the Email class to preconfigure shared settings in one place.

Creating your custom emails classes has a lot of synergy with mail events.

An example for a custom welcome email:

use Snicco\Component\BetterWPMail\ValueObject\Email;
use Snicco\Component\BetterWPMail\ValueObject\Mailbox;

class WelcomeEmail extends Email {
        
    // You can configure the protected
    // priorities of the Email class    
    protected ?int $priority = 5;
    
    protected string $text = 'We would like to welcome you to snicco.io';
    
    protected ?string $html_template = '/path/to/templates/welcome.php';
    
    public function __construct(WP_User $user) {
    
        // configure dynamic properties in the constructor.
        $this->subject = sprintf('Welcome to snicco.io %s', $user->display_name);
        
        $this->to[] = Mailbox::create($user);
        
        $this->context['first_name'] = $user->first_name;
        
    }

}

$user = new WP_User(1);

$mailer->send(new WelcomeEmail($user));

Using mail events

When you call Mailer::send two types of events are fired.

Right before passing the Email instance to the configured Transport the SendingEmail event is fired. This event contains the current Email as a public property which gives you an opportunity to change its settings before sending.

Right after an email is sent the EmailWasSent event is fired. This event is mainly useful for logging purposes.

To use mail events you have to pass an instance of MailEvents when creating your mailer instance.

By default, BetterWPMail comes with an implementation of this interface that uses the WordPress hook system.

use Snicco\Component\BetterWPMail\Event\MailEventsUsingWPHooks;
use Snicco\Component\BetterWPMail\Event\SendingEmail;
use Snicco\Component\BetterWPMail\Mailer;
use Snicco\Component\BetterWPMail\Transport\WPMailTransport;

$mailer = new Mailer(
    null,
    null,
    new MailEventsUsingWPHooks()
);

add_filter(Email::class, function (SendingEmail $event) {
    // This will add 'admin@site.com' to every email that is being sent.
    $event->email = $event->email->addBcc('admin@site.com');
});

add_filter(WelcomeEmail::class, function (SendingEmail $event) {
    // This will add 'welcome@site.com' to every welcome email that is sent.
    $event->email = $event->email->addBcc('welcome@site.com');
});

A common use-case of mail events is allowing users to customize specific mails:

// In your code
$user = new WP_User(1);
$mailer->send(new MyPluginWelcomeMail($user));

// Third-party code:
add_filter(MyPluginWelcomeMail::class, function (SendingEmail $event) {
    // This will overwrite your default template for the "MyPluginWelcomeEmail" only
    $event->email = $event->email->withHtmlTemplate('path/to/custom/welcome.php');
});

Writing emails in markdown / Using a custom MailRenderer

If you pass no arguments when creating your mailer instance the default renderer will be used which is a combination of:

Let's now create a custom setup:

First we need a way to convert markdown to HTML.

We will use erusev/parsedown for this task.

composer require erusev/parsedown

Now let's create a custom MarkdownMailRenderer:

use Snicco\Component\BetterWPMail\Renderer\MailRenderer;

class MarkdownEmailRenderer implements MailRenderer {
    
    // This renderer should only render .md files that exist.
    public function supports(string $template_name,?string $extension = null) : bool{
        
        return 'md' === $extension && is_file($template_name);
            
    }
    
    public function render(string $template_name,array $context = []) : string{
        
        // First, we get the string contents of the template.
        $contents = file_get_contents($template_name);
        
        // To allow basic templating, replace placeholders inside {{ }} 
        foreach ($context as $name => $value ) {
            $contents = str_replace('{{'.$name'.}}', $value);
        }
        
        // Convert the markdown to HTML and return it.
        return (new Parsedown())->text($contents);
                        
    }
    
}

Now that we are ready to render markdown emails we can create our Mailer like this:

use Snicco\Component\BetterWPMail\Mailer;
use Snicco\Component\BetterWPMail\Renderer\AggregateRenderer;
use Snicco\Component\BetterWPMail\Renderer\FilesystemRenderer;
use Snicco\Component\BetterWPMail\ValueObject\Email;

// This mail renderer will use our new markdown renderer (if possible) and default the filesystem renderer.
$mail_renderer = new AggregateRenderer(
    new MarkdownMailRenderer(),
    new FilesystemRenderer(),
);

$mailer = new Mailer(null, $mail_renderer);

$email = new Email();
$email = $email->addTo('calvin@snicco.io');

// This email will be renderer with the default renderer
$email_html = $email->withHtmlTemplate('/path/to/templates/welcome.php');
$mailer->send($email_html);

// This email will be renderer with our new markdown renderer.
$email_markdown= $email->withHtmlTemplate('/path/to/templates/markdown/welcome.md');
$mailer->send($email_markdown);

Handling exceptions

In contrast to wp_mail, calling Mailer::send() will throw a CantSendEmail exception on failure.

use Snicco\Component\BetterWPMail\Exception\CantSendEmail;
use Snicco\Component\BetterWPMail\ValueObject\Email;

$email = (new Email())->addTo('calvin@snicco.io')
                      ->withHtmlBody('<h1>BetterWPMail has awesome error handling</h1>');

try {
    $mailer->send($email);
} catch (CantSendEmail $e) {

   // You can catch this exception if you like,
   // or let it bubble up depending on your use case.
    error_log($e->getDebugData());
}

This has numerous advantages over the native way of interacting with wp_mail:

function handleMailError(WP_Error $error) {
    // what now?
}

add_action('wp_mail_failed', 'handleMailError');

$success = wp_mail('calvin@snicco.io', 'wp_mail has bad error_handling');

remove_action('wp_mail_failed', 'handleMailError');

if($success === false) {
    // what now?
}

Testing

BetterWPMail comes with a dedicated testing package that provides a FakeTransport class that you should use during testing.

First, install the package as a composer dev-dependency:

composer install --dev snicco/better-wp-mail-testing

How you wire the FakeTransport into your Mailer instance during testing greatly depends on how your overall codebase is set up. You probably want to do this inside your dependency-injection container.

The FakeTranport has the following phpunit assertion methods:

use Snicco\Component\BetterWPMail\Mailer;
use Snicco\Component\BetterWPMail\Testing\FakeTransport;
use Snicco\Component\BetterWPMail\ValueObject\Email;

$mailer = new Mailer($transport = new FakeTransport());

// This fill pass
$transport->assertNotSent(WelcomeEmail::class);

$mailer->send(new MyPluginWelcomeEmail());

// This will fail now.
$transport->assertNotSent(MyPluginWelcomeEmail::class);

// This will pass
$transport->assertSent(MyPluginWelcomeEmail::class);

// This will fail
$transport->assertSent(PurchaseEmail::class);

// This will fail
$transport->assertSentTimes(MyPluginWelcomeEmail:class, 2);

$mailer->send(new MyPluginWelcomeEmail());

// This will now pass.
$transport->assertSentTimes(MyPluginWelcomeEmail:class, 2);

$email = (new Email())->addTo('calvin@snicco.io');
$mailer->send($email);

// This will pass
$transport->assertSentTo('calvin@snicco.io');
// This will pass
$transport->assertNotSentTo('marlon@snicco.io');

$email = (new Email())->addTo('marlon@snicco.io');
$mailer->send($email);

// This will now fail.
$transport->assertNotSentTo('marlon@snicco.io');

// Using an assertion closure. This will pass.
$transport->assertSent(Email::class, function (Email $email) {
    return $email->to()->has('calvin@snicco.io')
});

Intercepting WordPress emails

In addition to faking emails send by your own code that uses the Mailer class, the FakeTransport also lets you fake all other emails that are sent directly by using wp_mail.

use Snicco\Component\BetterWPMail\Testing\FakeTransport;
use Snicco\Component\BetterWPMail\Testing\WPMail;

$transport = new FakeTransport()

$transport->interceptWordPressEmails();

// This will pass
$transport->assertNotSent(WPMail::class);

// No emails will be sent here.
wp_mail('calvin@snicco.io', 'Hi calvin', 'Testing WordPress emails was never this easy...');

// This will now fail.
$transport->assertNotSent(WPMail::class);

// This will pass
$transport->assertSent(WPMail::class);

// This will pass
$transport->assertSent(WPMail::class, function (WPMail $mail) {
    return 'Hi calvin' === $mail->subject();
});

// This will fail
$transport->assertSent(WPMail::class, function (WPMail $mail) {
    return 'Hi marlon' === $mail->subject();
});

Contributing

This repository is a read-only split of the development repo of the Snicco project.

This is how you can contribute.

Reporting issues and sending pull requests

Please report issues in the Snicco monorepo.

Security

If you discover a security vulnerability within BetterWPMail, please follow our disclosure procedure.