Permalink
Browse files

chore(email): Switch to `Zend\Mail` instead of built-in `mail()` func…

…tion

This lays a good foundation for improving Elgg's flexibility and security
in sending emails. Some of the features we open up because of this change:

 * HTML emails
 * Attachments
 * Improved security (automatic header escaping, etc.)
 * Third-party SMTP services (e.g. Mandrill)
 * Receiving emails
 * In-memory transport for easier testing/development

We aren't introducing our own interfaces because `Zend\Mail`
is plenty well supported and the API is agreeable to us.
Writing a wrapper would just be unnecessary overhead.

BREAKING CHANGE:
We are switching to `Zend\Mail` for sending emails in Elgg 2.0.
It's likely that there are some edge cases that the library
handles differently than Elgg 1.x used to. Take care to test
your email notifications carefully when upgrading to 2.0.

Fixes #5918
  • Loading branch information...
ewinslow authored and mrclay committed May 15, 2015
1 parent cf60e8a commit e9de196dfc7291a5870751f65a6ddee0952ef9cf
View
@@ -17,7 +17,8 @@
"knplabs/gaufrette": "~0.1.0",
"tedivm/stash": "~0.12",
"roave/security-advisories": "dev-master",
"elgg/login_as": "~1.9"
"elgg/login_as": "~1.9",
"zendframework/zend-mail": "~2.4"
},
"scripts": {
"test": "phpunit",
@@ -49,4 +50,4 @@
"config": {
"optimize-autoloader": true
}
}
}
@@ -18,6 +18,13 @@ Dropped login-over-https feature
For the best security and performance, serve all pages over HTTPS by switching
the scheme in your site's wwwroot to `https` at http://yoursite.tld/admin/settings/advanced
Introduced third-party library for sending email
------------------------------------------------
We are using the excellent ``Zend\Mail`` library to send emails in Elgg 2.0.
There are likely edge cases that the library handles differently than Elgg 1.x.
Take care to test your email notifications carefully when upgrading to 2.0.
All scripts moved to bottom of page
-----------------------------------
@@ -3,6 +3,7 @@
use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage;
use Symfony\Component\HttpFoundation\Session\Session as SymfonySession;
use Zend\Mail\Transport\TransportInterface as Mailer;
/**
* Provides common Elgg services.
@@ -34,6 +35,7 @@
* @property-read \Elgg\PluginHooksService $hooks
* @property-read \Elgg\Http\Input $input
* @property-read \Elgg\Logger $logger
* @property-read Mailer $mailer
* @property-read \Elgg\Cache\MetadataCache $metadataCache
* @property-read \Elgg\Database\MetadataTable $metadataTable
* @property-read \Elgg\Database\MetastringsTable $metastringsTable
@@ -154,6 +156,9 @@ public function __construct(\Elgg\Config $config) {
$this->setFactory('logger', function(ServiceProvider $c) {
return $this->resolveLoggerDependencies('logger');
});
// TODO(evan): Support configurable transports...
$this->setClassName('mailer', 'Zend\Mail\Transport\Sendmail');
$this->setFactory('metadataCache', function (ServiceProvider $c) {
return new \Elgg\Cache\MetadataCache($c->session);
@@ -0,0 +1,28 @@
<?php
namespace Elgg\Mail;
/**
* TODO(ewinslow): Contribute something like this back to Zend project.
*
* @access private
*/
class Address {
/**
* Parses strings like "Evan <evan@elgg.org>" into name/email objects.
*
* This is not very sophisticated and only used to provide a light BC effort.
*
* @param string $contact e.g. "Evan <evan@elgg.org>"
*
* @return \Zend\Mail\Address
*/
public static function fromString($contact) {
$containsName = preg_match('/<(.*)>/', $contact, $matches) == 1;
if ($containsName) {
$name = trim(elgg_substr($contact, 0, elgg_strpos($contact, '<')));
return new \Zend\Mail\Address($matches[1], $name);
} else {
return new \Zend\Mail\Address($contact);
}
}
}
@@ -1,4 +1,8 @@
<?php
use Elgg\Mail\Address;
use Zend\Mail\Message;
/**
* Adding a New Notification Event
* ===============================
@@ -577,6 +581,7 @@ function set_user_notification_setting($user_guid, $method, $value) {
return false;
}
/**
* Send an email to any email address
*
@@ -602,7 +607,7 @@ function elgg_send_email($from, $to, $subject, $body, array $params = null) {
$msg = "Missing a required parameter, '" . 'to' . "'";
throw new \NotificationException($msg);
}
$headers = array(
"Content-Type" => "text/plain; charset=UTF-8; format=flowed",
"MIME-Version" => "1.0",
@@ -623,62 +628,33 @@ function elgg_send_email($from, $to, $subject, $body, array $params = null) {
// compatibility. The latter is so handlers can now alter the contents/headers of
// the email by returning the array
$result = elgg_trigger_plugin_hook('email', 'system', $mail_params, $mail_params);
if (is_array($result)) {
foreach (array('to', 'from', 'subject', 'body', 'headers') as $key) {
if (isset($result[$key])) {
${$key} = $result[$key];
}
}
} elseif ($result !== null) {
if (!is_array($result) && $result !== null) {
return $result;
}
$header_eol = "\r\n";
if (isset($CONFIG->broken_mta) && $CONFIG->broken_mta) {
// Allow non-RFC 2822 mail headers to support some broken MTAs
$header_eol = "\n";
}
// Windows is somewhat broken, so we use just address for to and from
if (strtolower(substr(PHP_OS, 0, 3)) == 'win') {
// strip name from to and from
if (strpos($to, '<')) {
preg_match('/<(.*)>/', $to, $matches);
$to = $matches[1];
}
if (strpos($from, '<')) {
preg_match('/<(.*)>/', $from, $matches);
$from = $matches[1];
}
}
// make sure From is set
if (empty($headers['From'])) {
$headers['From'] = $from;
}
// strip name from to and from
$to_address = Address::fromString($result['to']);
$from_address = Address::fromString($result['from']);
// stringify headers
$headers_string = '';
foreach ($headers as $key => $value) {
$headers_string .= "$key: $value{$header_eol}";
}
$subject = html_entity_decode($result['subject'], ENT_QUOTES, 'UTF-8');
// Sanitise subject by stripping line endings
$subject = preg_replace("/(\r\n|\r|\n)/", " ", $subject);
// this is because Elgg encodes everything and matches what is done with body
$subject = html_entity_decode($subject, ENT_QUOTES, 'UTF-8'); // Decode any html entities
if (is_callable('mb_encode_mimeheader')) {
$subject = mb_encode_mimeheader($subject, "UTF-8", "B");
}
// Format message
$body = html_entity_decode($body, ENT_QUOTES, 'UTF-8'); // Decode any html entities
$body = html_entity_decode($result['body'], ENT_QUOTES, 'UTF-8');
$body = elgg_strip_tags($body); // Strip tags from message
$body = preg_replace("/(\r\n|\r)/", "\n", $body); // Convert to unix line endings in body
$body = preg_replace("/^From/", ">From", $body); // Change lines starting with From to >From
$body = wordwrap($body);
return mail($to, $subject, $body, $headers_string);
$message = new Message();
$message->addFrom($from_address);
$message->addTo($to_address);
$message->setSubject($subject);
$message->setBody($body);
foreach ($result['headers'] as $headerName => $headerValue) {
$message->getHeaders()->addHeaderLine($headerName, $headerValue);
}
return _elgg_services()->mailer->send($message);
}
/**
@@ -0,0 +1,24 @@
<?php
namespace Elgg\Mail;
use PHPUnit_Framework_TestCase as TestCase;
use Zend\Mail\Transport\InMemory as InMemoryTransport;
class MailerTest extends TestCase {
function testElggSendEmailPassesAllFieldsAsMessageToMailer() {
$mailer = new InMemoryTransport();
_elgg_services()->setValue('mailer', $mailer);
elgg_send_email("From <from@elgg.org>", "To <to@elgg.org>", "Dummy subject", "Dummy body");
$message = $mailer->getLastMessage();
$this->assertEquals('To', $message->getTo()->get('to@elgg.org')->getName());
$this->assertEquals('From', $message->getFrom()->get('from@elgg.org')->getName());
$this->assertEquals("Dummy subject", $message->getSubject());
$this->assertEquals("Dummy body", $message->getBodyText());
}
}

0 comments on commit e9de196

Please sign in to comment.