diff --git a/composer.json b/composer.json index f55f9dd38f326d0ce37231cd86c4b8fb3bd2061d..ed30be3b5ef7371ca1004f87c45c0307f255c12c 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "ext-mbstring": "*", "gettext/gettext": "^4.6", "jaimeperez/twig-configurable-i18n": "^2.0", + "phpmailer/phpmailer": "^6.0", "robrichards/xmlseclibs": "^3.0", "simplesamlphp/saml2": "^3.3", "simplesamlphp/simplesamlphp-module-cdc": "^1.0", diff --git a/composer.lock b/composer.lock index 233ced2b2f39182d21e470e7e8d107351a0baa24..07f1831d7dc071d4ac9a6f64892449d03505019a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "225bbfa4f3bdc5cac6a7943c5a705be5", + "content-hash": "b8847137c81850b370dc6f05691d31db", "packages": [ { "name": "gettext/gettext", @@ -222,6 +222,72 @@ ], "time": "2019-01-03T20:59:08+00:00" }, + { + "name": "phpmailer/phpmailer", + "version": "v6.0.7", + "source": { + "type": "git", + "url": "https://github.com/PHPMailer/PHPMailer.git", + "reference": "0c41a36d4508d470e376498c1c0c527aa36a2d59" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/0c41a36d4508d470e376498c1c0c527aa36a2d59", + "reference": "0c41a36d4508d470e376498c1c0c527aa36a2d59", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-filter": "*", + "php": ">=5.5.0" + }, + "require-dev": { + "doctrine/annotations": "1.2.*", + "friendsofphp/php-cs-fixer": "^2.2", + "phpdocumentor/phpdocumentor": "2.*", + "phpunit/phpunit": "^4.8 || ^5.7", + "zendframework/zend-eventmanager": "3.0.*", + "zendframework/zend-i18n": "2.7.3", + "zendframework/zend-serializer": "2.7.*" + }, + "suggest": { + "ext-mbstring": "Needed to send email in multibyte encoding charset", + "hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication", + "league/oauth2-google": "Needed for Google XOAUTH2 authentication", + "psr/log": "For optional PSR-3 debug logging", + "stevenmaguire/oauth2-microsoft": "Needed for Microsoft XOAUTH2 authentication", + "symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPMailer\\PHPMailer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "Jim Jagielski", + "email": "jimjag@gmail.com" + }, + { + "name": "Marcus Bointon", + "email": "phpmailer@synchromedia.co.uk" + }, + { + "name": "Andy Prevost", + "email": "codeworxtech@users.sourceforge.net" + }, + { + "name": "Brent R. Matzelle" + } + ], + "description": "PHPMailer is a full-featured email creation and transfer class for PHP", + "time": "2019-02-01T15:04:28+00:00" + }, { "name": "psr/container", "version": "1.0.0", diff --git a/lib/SimpleSAML/Utils/EMail.php b/lib/SimpleSAML/Utils/EMail.php new file mode 100644 index 0000000000000000000000000000000000000000..8644b0af967d38a805bd9af1c33d456b9dcb2ec0 --- /dev/null +++ b/lib/SimpleSAML/Utils/EMail.php @@ -0,0 +1,151 @@ +<?php + +namespace SimpleSAML\Utils; + +use PHPMailer\PHPMailer\PHPMailer; +use PHPMailer\PHPMailer\Exception; + +use SimpleSAML\Configuration; +use SimpleSAML\Logger; +use SimpleSAML\XHTML\Template; + +/** + * E-mailer class that can generate a formatted e-mail from array + * input data. + * + * @author Jørn Åne de Jong, Uninett AS <jorn.dejong@uninett.no> + * @package SimpleSAMLphp + */ + +class EMail +{ + + /** + * Get the default e-mail address from the configuration + * This is used both as source and destination address + * unless something else is provided at the constructor. + * + * It will refuse to return the SimpleSAMLphp default address, + * which is na@example.org. + * + * @return string Default mail address + */ + public static function getDefaultMailAddress() + { + $config = Configuration::getInstance(); + $address = $config->getString('technicalcontact_email', 'na@example.org'); + if ('na@example.org' === $address) { + throw new \Exception('technicalcontact_email must be changed from the default value'); + } + return $address; + } + + /** @var array Dictionary with multivalues */ + private $data; + /** @var string Introduction text */ + private $text; + /** @var PHPMailer The mailer instance */ + private $mail; + + /** + * Constructor + * + * If $from or $to is not provided, the <code>technicalcontact_email</code> + * from the configuration is used. + * + * @param string $subject The subject of the e-mail + * @param string $from The from-address (both envelope and header) + * @param string $to The recipient + * + * @throws PHPMailer\PHPMailer\Exception + */ + public function __construct($subject, $from = null, $to = null) + { + $this->mail = new PHPMailer(true); + $this->mail->Subject = $subject; + $this->mail->setFrom($from ?: static::getDefaultMailAddress()); + $this->mail->addAddress($to ?: static::getDefaultMailAddress()); + } + + /** + * Set the data that should be embedded in the e-mail body + * + * @param array $data The data that should be embedded in the e-mail body + */ + public function setData(array $data) + { + /* + * Convert every non-array value to an array with the original + * as its only element. This guarantees that every value of $data + * can be iterated over. + */ + $this->data = array_map(function($v){return is_array($v) ? $v : [$v];}, $data); + } + + /** + * Set an introduction text for the e-mail + * + * @param string $text Introduction text + */ + public function setText($text) + { + $this->text = $text; + } + + /** + * Add a Reply-To address to the mail + * + * @param string $address Reply-To e-mail address + */ + public function addReplyTo($address) + { + $this->mail->addReplyTo($address); + } + + /** + * Send the mail + * + * @param bool $plainTextOnly Do not send HTML payload + * + * @throws \PHPMailer\PHPMailer\Exception + */ + public function send($plainTextOnly = false) + { + if ($plainTextOnly) { + $this->mail->isHTML(false); + $this->mail->Body = $this->generateBody('mailtxt.twig'); + } else { + $this->mail->isHTML(true); + $this->mail->Body = $this->generateBody('mailhtml.twig'); + $this->mail->AltBody = $this->generateBody('mailtxt.twig'); + } + + $this->mail->send(); + } + + /** + * Generate the body of the e-mail + * + * @param string $template The name of the template to use + * + * @return string The body of the e-mail + */ + public function generateBody($template) + { + $config = Configuration::loadFromArray([ + 'usenewui' => true, + ]); + $t = new Template($config, $template); + $twig = $t->getTwig(); + if (is_bool($twig)) { + throw new \Exception('Even though we explicitly configure that we want Twig, the Template class does not give us Twig. This is a bug.'); + } + $result = $twig->render($template, [ + 'subject' => $this->mail->Subject, + 'text' => $this->text, + 'data' => $this->data + ]); + return $result; + } + +} diff --git a/lib/SimpleSAML/XHTML/EMail.php b/lib/SimpleSAML/XHTML/EMail.php deleted file mode 100644 index ac52e411147e948f09f421c67923e1ab1482b49a..0000000000000000000000000000000000000000 --- a/lib/SimpleSAML/XHTML/EMail.php +++ /dev/null @@ -1,124 +0,0 @@ -<?php - -namespace SimpleSAML\XHTML; - -/** - * A minimalistic Emailer class. Creates and sends HTML emails. - * - * @author Andreas Åkre Solberg, UNINETT AS. <andreas.solberg@uninett.no> - * @package SimpleSAMLphp - */ - -class EMail -{ - /** @var string|null */ - private $to = null; - - /** @var string|null */ - private $cc = null; - - /** @var string|null */ - private $body = null; - - /** @var string|null */ - private $from = null; - - /** @var string|null */ - private $replyto = null; - - /** @var string|null */ - private $subject = null; - - /** @var array */ - private $headers = []; - - - /** - * Constructor - * - * @param string $to - * @param string $subject - * @param string|null $from - * @param string|null $cc - * @param string|null $replyto - */ - public function __construct($to, $subject, $from = null, $cc = null, $replyto = null) - { - $this->to = $to; - $this->cc = $cc; - $this->from = $from; - $this->replyto = $replyto; - $this->subject = $subject; - } - - /** - * @param string $body - * @return void - */ - public function setBody($body) - { - $this->body = $body; - } - - - /** - * @param string $body - * @return string - */ - private function getHTML($body) - { - $config = \SimpleSAML\Configuration::getInstance(); - $t = new \SimpleSAML\XHTML\Template($config, 'errorreport_mail.twig'); - $twig = $t->getTwig(); - return $twig->render('errorreport_mail.twig', ['body' => $body]); - } - - - /** - * @return void - */ - public function send() - { - if ($this->to === null) { - throw new \Exception('EMail field [to] is required and not set.'); - } elseif ($this->subject === null) { - throw new \Exception('EMail field [subject] is required and not set.'); - } elseif ($this->body === null) { - throw new \Exception('EMail field [body] is required and not set.'); - } - - $random_hash = bin2hex(openssl_random_pseudo_bytes(16)); - - if (isset($this->from)) { - $this->headers[] = 'From: '.$this->from; - } - if (isset($this->replyto)) { - $this->headers[] = 'Reply-To: '.$this->replyto; - } - - $this->headers[] = 'Content-Type: multipart/alternative; boundary="simplesamlphp-'.$random_hash.'"'; - - $message = ' ---simplesamlphp-'.$random_hash.' -Content-Type: text/plain; charset="utf-8" -Content-Transfer-Encoding: 8bit - -'.strip_tags(html_entity_decode($this->body)).' - ---simplesamlphp-'.$random_hash.' -Content-Type: text/html; charset="utf-8" -Content-Transfer-Encoding: 8bit - -'.$this->getHTML($this->body).' - ---simplesamlphp-'.$random_hash.'-- -'; - $headers = implode("\n", $this->headers); - - $mail_sent = @mail($this->to, $this->subject, $message, $headers); - \SimpleSAML\Logger::debug('Email: Sending e-mail to ['.$this->to.'] : '.($mail_sent ? 'OK' : 'Failed')); - if (!$mail_sent) { - throw new \Exception('Error when sending e-mail'); - } - } -} diff --git a/modules/cron/www/cron.php b/modules/cron/www/cron.php index fb930592812ce20bf6370618cdb708677d63cb81..5dd7439d0a701953913229d8c10f0c64ba82a7b7 100644 --- a/modules/cron/www/cron.php +++ b/modules/cron/www/cron.php @@ -23,20 +23,9 @@ $croninfo = $cron->runTag($_REQUEST['tag']); $summary = $croninfo['summary']; if ($cronconfig->getValue('sendemail', true) && count($summary) > 0) { - $message = '<h1>Cron report</h1><p>Cron ran at '.$time.'</p>'. - '<p>URL: <code>'.$url.'</code></p>'. - '<p>Tag: '.$croninfo['tag']."</p>\n\n". - '<ul><li>'.join('</li><li>', $summary).'</li></ul>'; - - $toaddress = $config->getString('technicalcontact_email', 'na@example.org'); - if ($toaddress == 'na@example.org') { - \SimpleSAML\Logger::error('Cron - Could not send email. [technicalcontact_email] not set in config.'); - } else { - // Use $toaddress for both TO and FROM - $email = new \SimpleSAML\XHTML\EMail($toaddress, 'SimpleSAMLphp cron report', $toaddress); - $email->setBody($message); - $email->send(); - } + $mail = new \SimpleSAML\Utils\EMail('SimpleSAMLphp cron report'); + $mail->setData(['url' => $url, 'tag' => $croninfo['tag'], 'summary' => $croninfo['summary']]); + $mail->send(); } if (isset($_REQUEST['output']) && $_REQUEST['output'] == "xhtml") { diff --git a/templates/mailhtml.twig b/templates/mailhtml.twig new file mode 100644 index 0000000000000000000000000000000000000000..1f01ce16dad1b887da9b3425d7927a56e8e19c3c --- /dev/null +++ b/templates/mailhtml.twig @@ -0,0 +1,25 @@ +<style type="text/css"> +body { + font-family: sans-serif; +} +ul { + list-style: none; + padding-left: 1em; +} +p { + white-space: pre-line; +} +</style> +<h1>{{ subject }}</h1> + +<p>{{ text }}</p> + +{% for name, values in data %} +<h2>{{ name }}</h2> +<ul> +{% for value in values %} + <li><pre>{{ value }}</pre></li> +{% endfor %} +</ul> + +{% endfor %} diff --git a/templates/mailtxt.twig b/templates/mailtxt.twig new file mode 100644 index 0000000000000000000000000000000000000000..b560656da3d73d1d219d932fe95212be90a278c3 --- /dev/null +++ b/templates/mailtxt.twig @@ -0,0 +1,13 @@ +{% autoescape false %} +# {{ subject }} + +{{ text }} + +{% for name, values in data %} +## {{ name }} +{% for value in values %} +- {{ value }} +{% endfor %} + +{% endfor %} +{% endautoescape %} diff --git a/tests/lib/SimpleSAML/Utils/EMailTestCase.php b/tests/lib/SimpleSAML/Utils/EMailTestCase.php new file mode 100644 index 0000000000000000000000000000000000000000..0117d2e1ab26d3629869860c0a5248da40e091f9 --- /dev/null +++ b/tests/lib/SimpleSAML/Utils/EMailTestCase.php @@ -0,0 +1,74 @@ +<?php + +namespace SimpleSAML\Test\Utils; + +use SimpleSAML\Test\Utils\TestCase; + +use SimpleSAML\Configuration; +use SimpleSAML\Utils\EMail; + +/** + * A base SSP test case that tests some simple e-mail related calls + */ +class EMailTestCase extends ClearStateTestCase +{ + public function setUp() + { + parent::setUp(); + + // Override configuration + Configuration::loadFromArray([ + 'technicalcontact_email' => 'na@example.org', + ], '[ARRAY]', 'simplesaml'); + } + + /** + * Test that an exception is thrown if using default configuration, + * and no custom from address is specified. + * @expectedException Exception + */ + public function testMailFromDefaultConfigurationException() + { + new EMail('test', null, 'phpunit@simplesamlphp.org'); + } + + /** + * Test that an exception is thrown if using an invalid "From"-address + * @expectedException Exception + */ + public function testInvalidFromAddressException() + { + new EMail('test', "phpunit@simplesamlphp.org\nLorem Ipsum", 'phpunit@simplesamlphp.org'); + } + + /** + * Test that an exception is thrown if using an invalid "To"-address + * @expectedException Exception + */ + public function testInvalidToAddressException() + { + new EMail('test', 'phpunit@simplesamlphp.org', "phpunit@simplesamlphp.org\nLorem Ipsum"); + } + + /** + * Test that the data given is visible in the resulting mail + * @dataProvider mailTemplates + */ + public function testMailContents($template) + { + $mail = new EMail('subject-subject-subject-subject-subject-subject-subject', 'phpunit@simplesamlphp.org', 'phpunit@simplesamlphp.org'); + $mail->setText('text-text-text-text-text-text-text'); + $mail->setData(['key-key-key-key-key-key-key' => 'value-value-value-value-value-value-value']); + $result = $mail->generateBody($template); + $this->assertRegexp('/(subject-){6}/', $result); + $this->assertRegexp('/(text-){6}/', $result); + $this->assertRegexp('/(key-){6}/', $result); + $this->assertRegexp('/(value-){6}/', $result); + } + /** All templates that should be tested in #testMailContents($template) */ + public static function mailTemplates() + { + return [['mailtxt.twig'], ['mailhtml.twig']]; + } + +} diff --git a/www/errorreport.php b/www/errorreport.php index 0193e4767b589133c67e27bbef489fc0f9d072b9..cd4be1d79aac450dc6fc9f5508efe0aec846845f 100644 --- a/www/errorreport.php +++ b/www/errorreport.php @@ -13,9 +13,9 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } -$reportId = (string) $_REQUEST['reportId']; -$email = (string) $_REQUEST['email']; -$text = htmlspecialchars((string) $_REQUEST['text']); +$reportId = $_REQUEST['reportId']; +$email = $_REQUEST['email']; +$text = $_REQUEST['text']; $data = null; try { @@ -29,10 +29,8 @@ if ($data === null) { $data = [ 'exceptionMsg' => 'not set', 'exceptionTrace' => 'not set', - 'reportId' => $reportId, 'trackId' => 'not set', 'url' => 'not set', - 'version' => $config->getVersion(), 'referer' => 'not set', ]; @@ -41,90 +39,18 @@ if ($data === null) { } } -foreach ($data as $k => $v) { - $data[$k] = htmlspecialchars($v); -} - -// build the email message -$message = <<<MESSAGE -<h1>SimpleSAMLphp Error Report</h1> - -<p>Message from user:</p> -<div class="box" style="background: yellow; color: #888; border: 1px solid #999900; padding: .4em; margin: .5em"> - %s -</div> - -<p>Exception: <strong>%s</strong></p> -<pre>%s</pre> - -<p>URL:</p> -<pre><a href="%s">%s</a></pre> - -<p>Host:</p> -<pre>%s</pre> - -<p>Directory:</p> -<pre>%s</pre> - -<p>Track ID:</p> -<pre>%s</pre> - -<p>Version: <code>%s</code></p> - -<p>Report ID: <code>%s</code></p> - -<p>Referer: <code>%s</code></p> - -<hr /> -<div class="footer"> - This message was sent using SimpleSAMLphp. Visit the <a href="http://simplesamlphp.org/">SimpleSAMLphp homepage</a>. -</div> -MESSAGE; -$message = sprintf( - $message, - $text, - $data['exceptionMsg'], - $data['exceptionTrace'], - $data['url'], - $data['url'], - htmlspecialchars(php_uname('n')), - dirname(dirname(__FILE__)), - $data['trackId'], - $data['version'], - $data['reportId'], - $data['referer'] -); - -// add the email address of the submitter as the Reply-To address -$email = trim($email); - -// check that it looks like a valid email address -if (!preg_match('/\s/', $email) && strpos($email, '@') !== false) { - $replyto = $email; -} else { - $replyto = null; -} - -$from = $config->getString('sendmail_from', null); -if ($from === null || $from === '') { - $from = ini_get('sendmail_from'); - if ($from === '' || $from === false) { - $from = 'no-reply@example.org'; - } -} - -// If no sender email was configured at least set some relevant from address -if ($from === 'no-reply@example.org' && $replyto !== null) { - $from = $replyto; -} +$data['reportId'] = $reportId; +$data['version'] = $config->getVersion(); +$data['hostname'] = php_uname('n'); +$data['directory'] = dirname(dirname(__FILE__)); -// send the email -$toAddress = $config->getString('technicalcontact_email', 'na@example.org'); -if ($config->getBoolean('errorreporting', true) && $toAddress !== 'na@example.org') { - $email = new \SimpleSAML\XHTML\EMail($toAddress, 'SimpleSAMLphp error report', $from); - $email->setBody($message); - $email->send(); - SimpleSAML\Logger::error('Report with id '.$reportId.' sent to <'.$toAddress.'>.'); +if ($config->getBoolean('errorreporting', true)) { + $mail = new SimpleSAML\Utils\EMail('SimpleSAMLphp error report from '.$email); + $mail->setData($data); + $mail->addReplyTo($email); + $mail->setText($text); + $mail->send(); + SimpleSAML\Logger::error('Report with id '.$reportId.' sent'); } // redirect the user back to this page to clear the POST request