Skip to content

Instantly share code, notes, and snippets.

@thomasfw
Last active December 18, 2022 13:27
Embed
What would you like to do?
Enable non-filesystem attachments with wp_mail()
<?php
/**
* Plugin Name: Enhanced WP Mail Attachments
* Plugin URI: https://gist.github.com/thomasfw/5df1a041fd8f9c939ef9d88d887ce023/
* Version: 0.1
*/
/**
* Adds support for defining attachments as data arrays in wp_mail().
* Allows us to send string-based or binary attachments (non-filesystem)
* and gives us more control over the attachment data.
*
* @param array $atts Array of the `wp_mail()` arguments.
* - string|string[] $to Array or comma-separated list of email addresses to send message.
* - string $subject Email subject.
* - string $message Message contents.
* - string|string[] $headers Additional headers.
* - string|string[] $attachments Paths to files to attach.
*
* @see https://gist.github.com/thomasfw/5df1a041fd8f9c939ef9d88d887ce023/
*/
add_filter( 'wp_mail', function( $atts )
{
$attachment_arrays = [];
if ( array_key_exists('attachments', $atts) && isset($atts['attachments']) && $atts['attachments'] )
{
$attachments = $atts['attachments'];
if ( is_array($attachments) && ! empty($attachments) )
{
// Is the $attachments array a single array of attachment data, or an array containing multiple arrays of
// attachment data? (note that the array may also be a one-dimensional array of file paths, as-per default usage).
$is_multidimensional_array = count($attachments) == count($attachments, COUNT_RECURSIVE) ? false : true;
if ( ! $is_multidimensional_array ) $attachments = [ $attachments ];
// Work out which attachments we want to process here. If the value is an array with either
// a 'path' or 'path' key, then we'll process it separately and remove it from the
// $atts['attachments'] so that WP doesn't try to process it in wp_mail().
foreach ( $attachments as $index => $attachment )
{
if ( is_array($attachment) && (array_key_exists('path', $attachment) || array_key_exists('string', $attachment)) )
{
$attachment_arrays[] = $attachment;
if ( $is_multidimensional_array ) {
unset( $atts['attachments'][$index] );
} else {
$atts['attachments'] = [];
}
}
}
}
// Set the $wp_mail_attachments global to our attachment data.
// We'll read this later to check if any extra attachments should
// be added to the email. The value will be reset every time wp_mail()
// is called.
global $wp_mail_attachments;
$wp_mail_attachments = $attachment_arrays;
// We can't use the global $phpmailer to add our attachments directly in the 'wp_mail' filter callback because WP calls $phpmailer->clearAttachments()
// after this filter runs. Instead, we now hook into the 'phpmailer_init' action (triggered right before the email is sent), and read
// the $wp_mail_attachments global to check for any additional attachments to add.
add_action( 'phpmailer_init', function( \PHPMailer\PHPMailer\PHPMailer $phpmailer )
{
// Check the $wp_mail_attachments global for any attachment data, and reset it for good measure.
$attachment_arrays = [];
if ( array_key_exists('wp_mail_attachments', $GLOBALS) )
{
global $wp_mail_attachments;
$attachment_arrays = $wp_mail_attachments;
$wp_mail_attachments = [];
}
// Loop through our attachment arrays and attempt to add them using PHPMailer::addAttachment() or PHPMailer::addStringAttachment():
foreach ( $attachment_arrays as $attachment )
{
$is_filesystem_attachment = array_key_exists( 'path', $attachment ) ? true : false;
try
{
$encoding = $attachment['encoding'] ?? $phpmailer::ENCODING_BASE64;
$type = $attachment['type'] ?? '';
$disposition = $attachment['disposition'] ?? 'attachment';
if ( $is_filesystem_attachment )
{
$phpmailer->addAttachment( ($attachment['path'] ?? null), ($attachment['name'] ?? ''), $encoding, $type, $disposition );
}
else
{
$phpmailer->addStringAttachment( ($attachment['string'] ?? null), ($attachment['filename'] ?? ''), $encoding, $type, $disposition );
}
}
catch ( \PHPMailer\PHPMailer\Exception $e ) { continue; }
}
// var_dump( $phpmailer->getAttachments() ); // Debug the mail attachments, including those parsed by WP.
});
}
return $atts;
});
@thomasfw
Copy link
Author

thomasfw commented Jun 25, 2021

Installation

Drop the wpmail-enhanced-attachments.php file into your site's mu-plugins/ directory.

Usage

The filter allows us to pass additional attachment data through wp_mail() to the underling PHPMailer::addAttachment() and PHPMailer:: addStringAttachment() methods, crucially enabling us to attach files that aren't in the filesystem.

By default, when using the WordPress wp_mail() function, "the filenames in the $attachments attribute have to be filesystem paths." - https://developer.wordpress.org/reference/functions/wp_mail/#notes

WordPress Default

Adding attachments in the default manner, where $attachments can be string or string[] of filesystem paths:

wp_mail( $to, $subject, $message, $headers, './file.txt' );
wp_mail( $to, $subject, $message, $headers, [
    './file.txt', 
    './another-file.png',
]);

Adding a File Attachment

Any array value containing the path key will be treated as a file based attachment and added using the following method:

PHPMailer::addAttachment( $path, $name = '', $encoding = 'base64', $type = '', $disposition = 'attachment' )

// The format 
$FILE_ATTACHMENT = [
    'path' => './file.txt', // Path to the attachment (required)
    'name' => 'myfile.txt', // Optionally override the attachment name
    'encoding' => 'base64', // File encoding (defaults to 'base64')
    'type' => 'text/plain', // File MIME type (if left unspecified, PHPMailer will try to work it out from the file name)
    'disposition' => 'attachment', // Disposition to use (defaults to 'attachment')
];
wp_mail( $to, $subject, $message, $headers, $FILE_ATTACHMENT );

Adding a String Attachment

Any array value containing the string key will be treated as a string attachment and added using the following method:

PHPMailer::addStringAttachment( $string, $filename = '', $encoding = 'base64', $type = '', $disposition = 'attachment' )

$STRING_ATTACHMENT = [
    'string' => file_get_contents( $url ), // String attachment data (required)
    'filename' => 'myfile.pdf', // Name of the attachment (required)
    'encoding' => 'base64' // File encoding (defaults to 'base64')
    'type' => 'application/pdf', // File MIME type (if left unspecified, PHPMailer will try to work it out from the file name)
    'disposition' => 'attachment' // Disposition to use (defaults to 'attachment')
];
wp_mail( $to, $subject, $message, $headers, $STRING_ATTACHMENT );

So if for example you have a pdf generated with dompdf/dompdf, you can add it as an attachment like so:

$PDF_ATTACHMENT = [
    'string' => ( new \Dompdf\Dompdf() )->loadHtml( '<h1>Hello, World!</h1>' )->render()->output(),
    'filename' => 'helloworld.pdf',
    'encoding' => 'base64',
    'type' =>  'application/pdf',
];
wp_mail( $to, $subject, $message, $headers, $PDF_ATTACHMENT );

Combining Attachment Types

The value of $attachments may contain a mixture of string filesystem paths and arrays containing your attachment data:

 wp_mail( $to, $subject, $message, $headers, [ 
    $PDF_ATTACHMENT,
    $FILE_ATTACHMENT, 
    $STRING_ATTACHMENT,  
    './file.txt', 
    './another-file.png',
    [ 'string' => file_get_contents( $url ), 'filename' => 'another-doc.pdf', 'type' =>  'application/pdf' ],
]);

@flymikeGit
Copy link

PHP Fatal error: Uncaught ErrorException: Attempt to assign property "___wp_attachments" on null in /xxxs/enhanced-wpmail-attachments/enhanced-wpmail-attachments.php:56

At the time apply_filters for wp_mail is called, the global $phpmailer may not have been initialized, or gone missing for some reason. The wp_mail function itself checks and corrects that, if necessary, around line 257 - unfortunately after the filters have been applied.

@thomasfw
Copy link
Author

thomasfw commented Jan 3, 2022

@flymikeGit - I've updated this to set the attachment data elsewhere instead of on the global $phpmailer instance (which as you reported, may or may not be available at the time the filter is applied).

However, this change is untested for now until I get more time to check it. Feel free to try it and let me know if it's working for you.

@flymikeGit
Copy link

Yes. I can confirm that change solves the problem. Thanks!

@robwent
Copy link

robwent commented Feb 28, 2022

Thanks for this!

I think the 'name' and 'filename' parameters are wrong in the examples.

In the foreach loop, files use 'name' and strings use 'filename'.

Switching my string to use 'filename' stopped the attachment coming through as 'noname'.

@thomasfw
Copy link
Author

thomasfw commented Mar 1, 2022

@robwent good catch, examples have been updated!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment