Quite a few years ago I wrote small wiki note about templated emails and inline images/CSS.
I never really got around to blog about it, let alone covering the 3.x version of it.
There was a ticket opened recently regarding native CID replacement for the CakePHP core Email class.
I am not too convinced of an "always convert" kind of solution here, if only < 10% of the people might actually need this.
So what is a good working solution that can manually be added on top if needed, and otherwise be left out to avoid false positives and to keep things fast and simple?
The following solution is a working approach in CakePHP 3.x.
Inline images
When sending templated HTML emails, your layout.ctp or template.ctp contains usually a few images. Those you should transform into inline attachments if you want them displayed in the email (external ones are usually blocked and that does not look nice).
Those so called embedded images are added inside the email as content, surrounded by a CID identifier and the image tag itself then linked to the content via this CID.
For obvious reasons there are few important things to consider:
- The same image (like a separator or logo) should only be added once as content and get the same CID.
- There should be manual control over which of those images are supposed to be inline included if needed. Some for size or file type reasons might deliberately have to be left out (and kept as as external URL).
So what is the alternative to a magic "always run" approach?
First of all, we have been using the Tools plugin Email class which extends the core one.
But on top of the existing functionality it provides explicit API for adding embedded attachments:
- addEmbeddedAttachment()
- addEmbeddedBlobAttachment()
So the only thing to change is the use statement really:
use Tools/Mailer/Email;
$this->Email = new Email();
...
$html = $this->prepareHtmlContent($html);
$this->Email->viewVars(compact('html'));
$this->Email->send();
In our case, we had a prepareHtmlContent()
method that we executed before passing in the view vars and sending the email:
/**
* @param string $html HTML
* @return string HTML
*/
public function prepareHtmlContent($html) {
// Convert inline (CSS) images to CID for HTML emails
$html = $this->_convertImages($html);
// Make relative URLs absolute
$html = $this->_convertUrls($html);
// ... you can do more stuff here
return $html;
}
/**
* @param string $html
* @return string HTML
*/
protected function _convertImages($html) {
// all image tags
$this->imageType = 'src';
$html = preg_replace_callback('~<img.*?src=[\'"]([\/.a-z0-9:_-]+).*?>~si', [$this, '_convertImage'], $html);
// anything with a background attribute
$this->imageType = 'background';
$html = preg_replace_callback('~<.*?background=[\'"]+([\/.a-z0-9:_-]+).*?>~si', [$this, '_convertImage'], $html);
// list-style-image and background-image
$html = preg_replace_callback('/-image:\s*url[ (\'"]+([\/.a-z0-9:_-]+)./si', [$this, '_convertBackgroundImage'], $html);
return $html;
}
/**
* @param array $matches
* @return string
*/
protected function _convertImage($matches) {
if (substr($matches[1], 0, 4) === 'cid:') {
return $matches[0];
}
$type = $this->imageType;
// ... some custom things here, e.g. when from a specific pattern/URL
$cid = $this->_attachImage($matches[1]);
if (!$cid) {
return $matches[0];
}
return str_replace($type . '="' . $matches[1] . '"', $type . '="cid:' . $cid . '"', $matches[0]);
}
/**
* @param array $matches
* @return string
*/
protected function _convertBackgroundImage($matches) {
if (substr($matches[1], 0, 4) === 'cid:') {
return $matches[0];
}
$cid = $this->_attachImage($matches[1]);
if (!$cid) {
return $matches[0];
}
return '-image: url(cid:' . $cid . ')';
}
And _attachImage()
would then simply transfer the path to a local path and call $this->Email->addEmbeddedAttachment($pathToFile)
.
If not possible because some external file, it could also fetch the external content via request if necessary and then add it as blob attachment:
$fileName = pathinfo($pathToFile, PATHINFO_BASENAME);
$cid = $this->Email->addEmbeddedBlobAttachment(file_get_contents($pathToFile), $fileName);
return $cid;
An alternative to this manual approach (which gives you maximum control, though) would be a helper (see the next paragraph on the basic ideas) which can to that in its afterLayout()
callback.
Inline CSS
We all know that clean HTML templates would not contain a single inline CSS declaration. Otherwise it is really hard to maintain.
They should use classes and (external) style definitions then use those to apply styling.
With emails, though, you cannot have external stylesheets. And even internal <style>
blocks will not work usually.
So to be cross-email-browser safe CSS should be transformed into inline CSS upon sending.
We used an emogrifier extension as library class to transform the CSS and then wrapped the whole thing as helper for the Email object:
use App\Email\InlineCss;
class EmailProcessingHelper extends AppHelper {
/**
* Process Email HTML content after rendering of the email
*
* @param string $layoutFile The layout file that was rendered.
* @return void
*/
public function afterLayout($layoutFile) {
$content = $this->_View->Blocks->get('content');
$content = $this->_prepareHtmlContent($content); // Transforming images/urls
if (!isset($this->InlineCss)) {
$this->InlineCss = new InlineCss();
}
$content = trim($this->InlineCss->process($content)); // Transforming CSS
$this->_View->Blocks->set('content', $content);
}
}`
Then one can add the helper to the Email object:
$this->Email->helpers(['EmailProcessing']);
This would now convert the complete <html>
part internally to inline CSS. This includes any <style>
tags of your <head>
section which depending on the tool being used would then also be removed from the markup since they now are not needed anymore.
Moving it all to the helper
You can, as said before, move everything to the helper. But then you need a way to write back into the Email object from within the helper.
And in our case we even had to write back to the model to "collect all URLs" being transformed in order to log them along with the send report.
The code here is still from 2.x, maybe there is a better 3.x way. But the idea here which was working fine was simply for the Email object to pass itself into the helper:
$this->Email->helpers(['EmailProcessing' => ['email' => $this->Email]]);
Then the modified helper could look like:
use App\Email\InlineCss;
class EmailProcessingHelper extends AppHelper {
/**
* @param string $layoutFile The layout file that was rendered.
* @return void
*/
public function afterLayout($layoutFile) {
$Messages = TableRegistry::get('Messages');
$Messages->Email = $this->_config['email'];
$content = $this->_View->Blocks->get('content');
// Now we are calling the method from above
$content = $Message->prepareHtmlContent($content);
// ... add more modification in this helper
if (!isset($this->InlineCss)) {
$this->InlineCss = new InlineCss();
}
$content = trim($this->InlineCss->process($content)); // Transforming CSS
$this->_View->Blocks->set('content', $content);
}
}`
What else
Additionally you can use the helper to transform any absolute non-protocol URLs to ones with http://
added. So clicking the links from emails will actually work, in case you are using snippets that don’t automatically make full URLs.
I bet there are more use cases of post-processing generated emails prior to actually delivering them.
Since file size can be an issue with templated emails, make sure you exclude too large images from becoming embedded assets.
You can also remove comments or any other not needed markup in the emails.
We also made sure the former classes and ids (which now are not needed anymore) are removed from the final HTML markup.
Tips
Provide both HTML and text version
You can also easily provide text fallback for your HTML emails without any overhead. Some people prefer a text-only default preview.
If their browser is set up like that you might want to at least display basic information then.
$this->Email->emailFormat('both');
$html = $this->prepareHtmlContent($html);
$text = $this->prepareTextContent($html);
$this->Email->viewVars(compact('text', 'html'));
/**
* @param string $html
* @return string Text
*/
public function prepareTextContent($html) {
// Convert <br> to \n
$text = preg_replace('/<br(\s+)?\/?>/i', "\n", $html);
// Remove HTML markup
$text = trim(strip_tags($text));
// Replace multiple (two ore more) line breaks with a single one.
$text = preg_replace("/(\r\n|\r|\n)+(\r\n|\r|\n)+/", "\n\n", $text);
return $text;
}
A word of caution
This approach is fine if all files are coming from (internal) trusted sources.
You might need an additional layer of security in case you are dealing with user inputted templates or images outside of your domain.
Bottom line
The task usually consists of a few elements and is almost too app-specific to really be something generic we can whip out.
So it is better to leverage some customizable tooling here you can have a bit more control over and that can react to certain special cases (like "not beyond certain sizes" and alike).
Feel free to share your "improvements" on my quite a bit ancient – but still perfectly working – examples from above.