Improve email sending system and add Postmark as mail transport

This commit is contained in:
Frederic Guillot
2015-06-06 14:10:31 -04:00
parent c87e1fbc33
commit 9d9e3afba2
15 changed files with 284 additions and 137 deletions

View File

@@ -112,7 +112,7 @@ class Webhook extends Base
$this->response->text('Not Authorized', 401);
}
echo $this->postmarkWebhook->parsePayload($this->request->getJson() ?: array()) ? 'PARSED' : 'IGNORED';
echo $this->postmark->receiveEmail($this->request->getJson() ?: array()) ? 'PARSED' : 'IGNORED';
}
/**

View File

@@ -11,6 +11,7 @@ use Pimple\Container;
* @author Frederic Guillot
*
* @property \Core\Helper $helper
* @property \Core\EmailClient $emailClient
* @property \Core\HttpClient $httpClient
* @property \Core\Paginator $paginator
* @property \Core\Request $request
@@ -22,9 +23,10 @@ use Pimple\Container;
* @property \Integration\HipchatWebhook $hipchatWebhook
* @property \Integration\Jabber $jabber
* @property \Integration\MailgunWebhook $mailgunWebhook
* @property \Integration\PostmarkWebhook $postmarkWebhook
* @property \Integration\Postmark $postmark
* @property \Integration\SendgridWebhook $sendgridWebhook
* @property \Integration\SlackWebhook $slackWebhook
* @property \Integration\Smtp $smtp
* @property \Model\Acl $acl
* @property \Model\Action $action
* @property \Model\Authentication $authentication

43
app/Core/EmailClient.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
namespace Core;
/**
* Mail client
*
* @package core
* @author Frederic Guillot
*/
class EmailClient extends Base
{
/**
* Send a HTML email
*
* @access public
* @param string $email
* @param string $name
* @param string $subject
* @param string $html
*/
public function send($email, $name, $subject, $html)
{
$this->container['logger']->debug('Sending email to '.$email.' ('.MAIL_TRANSPORT.')');
$start_time = microtime(true);
$author = 'Kanboard';
if (Session::isOpen() && $this->userSession->isLogged()) {
$author = e('%s via Kanboard', $this->user->getFullname($this->session['user']));
}
switch (MAIL_TRANSPORT) {
case 'postmark':
$this->postmark->sendEmail($email, $name, $subject, $html, $author);
break;
default:
$this->smtp->sendEmail($email, $name, $subject, $html, $author);
}
$this->container['logger']->debug('Email sent in '.round(microtime(true) - $start_time, 6).' seconds');
}
}

View File

@@ -2,22 +2,20 @@
namespace Core;
use Pimple\Container;
/**
* HTTP client
*
* @package core
* @author Frederic Guillot
*/
class HttpClient
class HttpClient extends Base
{
/**
* HTTP connection timeout in seconds
*
* @var integer
*/
const HTTP_TIMEOUT = 2;
const HTTP_TIMEOUT = 5;
/**
* Number of maximum redirections for the HTTP client
@@ -31,26 +29,7 @@ class HttpClient
*
* @var string
*/
const HTTP_USER_AGENT = 'Kanboard Webhook';
/**
* Container instance
*
* @access protected
* @var \Pimple\Container
*/
protected $container;
/**
* Constructor
*
* @access public
* @param \Pimple\Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
}
const HTTP_USER_AGENT = 'Kanboard';
/**
* Send a POST HTTP request
@@ -58,19 +37,20 @@ class HttpClient
* @access public
* @param string $url
* @param array $data
* @param array $headers
* @return string
*/
public function post($url, array $data)
public function post($url, array $data, array $headers = array())
{
if (empty($url)) {
return '';
}
$headers = array(
$headers = array_merge(array(
'User-Agent: '.self::HTTP_USER_AGENT,
'Content-Type: application/json',
'Connection: close',
);
), $headers);
$context = stream_context_create(array(
'http' => array(
@@ -83,12 +63,21 @@ class HttpClient
)
));
$response = @file_get_contents(trim($url), false, $context);
$stream = @fopen(trim($url), 'r', false, $context);
$response = '';
if (is_resource($stream)) {
$response = stream_get_contents($stream);
}
else {
$this->container['logger']->error('HttpClient: request failed');
}
if (DEBUG) {
$this->container['logger']->debug($url);
$this->container['logger']->debug(var_export($data, true));
$this->container['logger']->debug($response);
$this->container['logger']->debug('HttpClient: url='.$url);
$this->container['logger']->debug('HttpClient: payload='.var_export($data, true));
$this->container['logger']->debug('HttpClient: metadata='.var_export(@stream_get_meta_data($stream), true));
$this->container['logger']->debug('HttpClient: response='.$response);
}
return $response;

View File

@@ -5,13 +5,40 @@ namespace Integration;
use HTML_To_Markdown;
/**
* Postmark Webhook
* Postmark integration
*
* @package integration
* @author Frederic Guillot
*/
class PostmarkWebhook extends \Core\Base
class Postmark extends \Core\Base
{
/**
* Send a HTML email
*
* @access public
* @param string $email
* @param string $name
* @param string $subject
* @param string $html
* @param string $author
*/
public function sendEmail($email, $name, $subject, $html, $author)
{
$headers = array(
'Accept: application/json',
'X-Postmark-Server-Token: '.POSTMARK_API_TOKEN,
);
$payload = array(
'From' => sprintf('%s <%s>', $author, MAIL_FROM),
'To' => sprintf('%s <%s>', $name, $email),
'Subject' => $subject,
'HtmlBody' => $html,
);
$this->httpClient->post('https://api.postmarkapp.com/email', $payload, $headers);
}
/**
* Parse incoming email
*
@@ -19,7 +46,7 @@ class PostmarkWebhook extends \Core\Base
* @param array $payload Incoming email
* @return boolean
*/
public function parsePayload(array $payload)
public function receiveEmail(array $payload)
{
if (empty($payload['From']) || empty($payload['Subject']) || empty($payload['MailboxHash'])) {
return false;
@@ -29,7 +56,7 @@ class PostmarkWebhook extends \Core\Base
$user = $this->user->getByEmail($payload['From']);
if (empty($user)) {
$this->container['logger']->debug('PostmarkWebhook: ignored => user not found');
$this->container['logger']->debug('Postmark: ignored => user not found');
return false;
}
@@ -37,13 +64,13 @@ class PostmarkWebhook extends \Core\Base
$project = $this->project->getByIdentifier($payload['MailboxHash']);
if (empty($project)) {
$this->container['logger']->debug('PostmarkWebhook: ignored => project not found');
$this->container['logger']->debug('Postmark: ignored => project not found');
return false;
}
// The user must be member of the project
if (! $this->projectPermission->isMember($project['id'], $user['id'])) {
$this->container['logger']->debug('PostmarkWebhook: ignored => user is not member of the project');
$this->container['logger']->debug('Postmark: ignored => user is not member of the project');
return false;
}

71
app/Integration/Smtp.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace Integration;
use Swift_Message;
use Swift_Mailer;
use Swift_MailTransport;
use Swift_SendmailTransport;
use Swift_SmtpTransport;
use Swift_TransportException;
/**
* Smtp
*
* @package integration
* @author Frederic Guillot
*/
class Smtp extends \Core\Base
{
/**
* Send a HTML email
*
* @access public
* @param string $email
* @param string $name
* @param string $subject
* @param string $html
* @param string $author
*/
public function sendEmail($email, $name, $subject, $html, $author)
{
try {
$message = Swift_Message::newInstance()
->setSubject($subject)
->setFrom(array(MAIL_FROM => $author))
->setBody($html, 'text/html')
->setTo(array($email => $name));
Swift_Mailer::newInstance($this->getTransport())->send($message);
}
catch (Swift_TransportException $e) {
$this->container['logger']->error($e->getMessage());
}
}
/**
* Get SwiftMailer transport
*
* @access private
* @return \Swift_Transport
*/
private function getTransport()
{
switch (MAIL_TRANSPORT) {
case 'smtp':
$transport = Swift_SmtpTransport::newInstance(MAIL_SMTP_HOSTNAME, MAIL_SMTP_PORT);
$transport->setUsername(MAIL_SMTP_USERNAME);
$transport->setPassword(MAIL_SMTP_PASSWORD);
$transport->setEncryption(MAIL_SMTP_ENCRYPTION);
break;
case 'sendmail':
$transport = Swift_SendmailTransport::newInstance(MAIL_SENDMAIL_COMMAND);
break;
default:
$transport = Swift_MailTransport::newInstance();
}
return $transport;
}
}

View File

@@ -4,9 +4,6 @@ namespace Model;
use Core\Session;
use Core\Translator;
use Swift_Message;
use Swift_Mailer;
use Swift_TransportException;
/**
* Notification model
@@ -101,43 +98,22 @@ class Notification extends Base
*/
public function sendEmails($template, array $users, array $data)
{
try {
foreach ($users as $user) {
$author = '';
if (Session::isOpen() && $this->userSession->isLogged()) {
$author = e('%s via Kanboard', $this->user->getFullname($this->session['user']));
// Use the user language otherwise use the application language (do not use the session language)
if (! empty($user['language'])) {
Translator::load($user['language']);
}
else {
Translator::load($this->config->get('application_language', 'en_US'));
}
$mailer = Swift_Mailer::newInstance($this->container['mailer']);
foreach ($users as $user) {
$this->container['logger']->debug('Send email notification to '.$user['username'].' lang='.$user['language']);
$start_time = microtime(true);
// Use the user language otherwise use the application language (do not use the session language)
if (! empty($user['language'])) {
Translator::load($user['language']);
}
else {
Translator::load($this->config->get('application_language', 'en_US'));
}
// Send the message
$message = Swift_Message::newInstance()
->setSubject($this->getMailSubject($template, $data))
->setFrom(array(MAIL_FROM => $author ?: 'Kanboard'))
->setBody($this->getMailContent($template, $data), 'text/html')
->setTo(array($user['email'] => $user['name'] ?: $user['username']));
$mailer->send($message);
$this->container['logger']->debug('Email sent in '.round(microtime(true) - $start_time, 6).' seconds');
}
}
catch (Swift_TransportException $e) {
$this->container['logger']->error($e->getMessage());
$this->emailClient->send(
$user['email'],
$user['name'] ?: $user['username'],
$this->getMailSubject($template, $data),
$this->getMailContent($template, $data)
);
}
// Restore locales
@@ -167,40 +143,40 @@ class Notification extends Base
{
switch ($template) {
case 'file_creation':
$subject = $this->getStandardMailSubject(t('New attachment'), $data);
$subject = $this->getStandardMailSubject(e('New attachment'), $data);
break;
case 'comment_creation':
$subject = $this->getStandardMailSubject(t('New comment'), $data);
$subject = $this->getStandardMailSubject(e('New comment'), $data);
break;
case 'comment_update':
$subject = $this->getStandardMailSubject(t('Comment updated'), $data);
$subject = $this->getStandardMailSubject(e('Comment updated'), $data);
break;
case 'subtask_creation':
$subject = $this->getStandardMailSubject(t('New subtask'), $data);
$subject = $this->getStandardMailSubject(e('New subtask'), $data);
break;
case 'subtask_update':
$subject = $this->getStandardMailSubject(t('Subtask updated'), $data);
$subject = $this->getStandardMailSubject(e('Subtask updated'), $data);
break;
case 'task_creation':
$subject = $this->getStandardMailSubject(t('New task'), $data);
$subject = $this->getStandardMailSubject(e('New task'), $data);
break;
case 'task_update':
$subject = $this->getStandardMailSubject(t('Task updated'), $data);
$subject = $this->getStandardMailSubject(e('Task updated'), $data);
break;
case 'task_close':
$subject = $this->getStandardMailSubject(t('Task closed'), $data);
$subject = $this->getStandardMailSubject(e('Task closed'), $data);
break;
case 'task_open':
$subject = $this->getStandardMailSubject(t('Task opened'), $data);
$subject = $this->getStandardMailSubject(e('Task opened'), $data);
break;
case 'task_move_column':
$subject = $this->getStandardMailSubject(t('Column Change'), $data);
$subject = $this->getStandardMailSubject(e('Column Change'), $data);
break;
case 'task_move_position':
$subject = $this->getStandardMailSubject(t('Position Change'), $data);
$subject = $this->getStandardMailSubject(e('Position Change'), $data);
break;
case 'task_assignee_change':
$subject = $this->getStandardMailSubject(t('Assignee Change'), $data);
$subject = $this->getStandardMailSubject(e('Assignee Change'), $data);
break;
case 'task_due':
$subject = e('[%s][Due tasks]', $data['project']);

View File

@@ -64,6 +64,7 @@ class ClassProvider implements ServiceProviderInterface
'Webhook',
),
'Core' => array(
'EmailClient',
'Helper',
'HttpClient',
'MemoryCache',
@@ -78,9 +79,10 @@ class ClassProvider implements ServiceProviderInterface
'HipchatWebhook',
'Jabber',
'MailgunWebhook',
'PostmarkWebhook',
'Postmark',
'SendgridWebhook',
'SlackWebhook',
'Smtp',
)
);

View File

@@ -1,33 +0,0 @@
<?php
namespace ServiceProvider;
use Pimple\Container;
use Pimple\ServiceProviderInterface;
use Swift_SmtpTransport;
use Swift_SendmailTransport;
use Swift_MailTransport;
class MailerProvider implements ServiceProviderInterface
{
public function register(Container $container)
{
$container['mailer'] = function () {
switch (MAIL_TRANSPORT) {
case 'smtp':
$transport = Swift_SmtpTransport::newInstance(MAIL_SMTP_HOSTNAME, MAIL_SMTP_PORT);
$transport->setUsername(MAIL_SMTP_USERNAME);
$transport->setPassword(MAIL_SMTP_PASSWORD);
$transport->setEncryption(MAIL_SMTP_ENCRYPTION);
break;
case 'sendmail':
$transport = Swift_SendmailTransport::newInstance(MAIL_SENDMAIL_COMMAND);
break;
default:
$transport = Swift_MailTransport::newInstance();
}
return $transport;
};
}
}

View File

@@ -27,4 +27,3 @@ $container->register(new ServiceProvider\LoggingProvider);
$container->register(new ServiceProvider\DatabaseProvider);
$container->register(new ServiceProvider\ClassProvider);
$container->register(new ServiceProvider\EventDispatcherProvider);
$container->register(new ServiceProvider\MailerProvider);

View File

@@ -64,6 +64,7 @@ defined('MAIL_SMTP_USERNAME') or define('MAIL_SMTP_USERNAME', '');
defined('MAIL_SMTP_PASSWORD') or define('MAIL_SMTP_PASSWORD', '');
defined('MAIL_SMTP_ENCRYPTION') or define('MAIL_SMTP_ENCRYPTION', null);
defined('MAIL_SENDMAIL_COMMAND') or define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs');
defined('POSTMARK_API_TOKEN') or define('POSTMARK_API_TOKEN', '');
// Enable or disable "Strict-Transport-Security" HTTP header
defined('ENABLE_HSTS') or define('ENABLE_HSTS', true);

View File

@@ -14,7 +14,7 @@ define('FILES_DIR', 'data/files/');
// E-mail address for the "From" header (notifications)
define('MAIL_FROM', 'notifications@kanboard.local');
// Mail transport to use: "smtp", "sendmail" or "mail" (PHP mail function)
// Mail transport to use: "smtp", "sendmail", "mail" (PHP mail function), "postmark"
define('MAIL_TRANSPORT', 'mail');
// SMTP configuration to use when the "smtp" transport is chosen
@@ -27,6 +27,9 @@ define('MAIL_SMTP_ENCRYPTION', null); // Valid values are "null", "ssl" or "tls"
// Sendmail command to use when the transport is "sendmail"
define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs');
// Postmark API token (used to send emails through their API)
define('POSTMARK_API_TOKEN', '');
// Database driver: sqlite, mysql or postgres (sqlite by default)
define('DB_DRIVER', 'sqlite');

View File

@@ -12,6 +12,16 @@ To receive email notifications, users of Kanboard must have:
Note: The logged user who performs the action doesn't receive any notifications, only other project members.
Email transports
----------------
There are several email transports available:
- SMTP
- Sendmail
- PHP native mail function
- Postmark
Server settings
---------------
@@ -57,6 +67,34 @@ define('MAIL_TRANSPORT', 'sendmail');
define('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs');
```
### PHP native mail function
This is the default configuration:
```php
define('MAIL_TRANSPORT', 'mail');
```
### Postmark HTTP API
Postmark is a third-party email service.
If you already use the Postmark integration to receive emails in Kanboard you can use the same provider to send email too.
This system use their HTTP API instead of the SMTP protocol.
Here are the required settings for this configuration:
```php
// We choose "postmark" as mail transport
define('MAIL_TRANSPORT', 'postmark');
// Copy and paste your Postmark API token
define('POSTMARK_API_TOKEN', 'COPY HERE YOUR POSTMARK API TOKEN');
// Be sure to use the Postmark configured sender email address
define('MAIL_FROM', 'sender-address-configured-in-postmark@example.org');
```
### The sender email address
By default, emails will use the sender address `notifications@kanboard.local`.

View File

@@ -15,6 +15,7 @@ class FakeHttpClient
{
private $url = '';
private $data = array();
private $headers = array();
public function getUrl()
{
@@ -26,16 +27,21 @@ class FakeHttpClient
return $this->data;
}
public function getHeaders()
{
return $this->headers;
}
public function toPrettyJson()
{
return json_encode($this->data, JSON_PRETTY_PRINT);
}
public function post($url, array $data)
public function post($url, array $data, array $headers = array())
{
$this->url = $url;
$this->data = $data;
//echo $this->toPrettyJson();
$this->headers = $headers;
return true;
}
}

View File

@@ -2,18 +2,41 @@
require_once __DIR__.'/Base.php';
use Integration\PostmarkWebhook;
use Integration\Postmark;
use Model\TaskCreation;
use Model\TaskFinder;
use Model\Project;
use Model\ProjectPermission;
use Model\User;
class PostmarkWebhookTest extends Base
class PostmarkTest extends Base
{
public function testSendEmail()
{
$pm = new Postmark($this->container);
$pm->sendEmail('test@localhost', 'Me', 'Test', 'Content', 'Bob');
$this->assertEquals('https://api.postmarkapp.com/email', $this->container['httpClient']->getUrl());
$data = $this->container['httpClient']->getData();
$this->assertArrayHasKey('From', $data);
$this->assertArrayHasKey('To', $data);
$this->assertArrayHasKey('Subject', $data);
$this->assertArrayHasKey('HtmlBody', $data);
$this->assertEquals('Me <test@localhost>', $data['To']);
$this->assertEquals('Bob <notifications@kanboard.local>', $data['From']);
$this->assertEquals('Test', $data['Subject']);
$this->assertEquals('Content', $data['HtmlBody']);
$this->assertContains('Accept: application/json', $this->container['httpClient']->getHeaders());
$this->assertContains('X-Postmark-Server-Token: ', $this->container['httpClient']->getHeaders());
}
public function testHandlePayload()
{
$w = new PostmarkWebhook($this->container);
$w = new Postmark($this->container);
$p = new Project($this->container);
$pp = new ProjectPermission($this->container);
$u = new User($this->container);
@@ -26,20 +49,20 @@ class PostmarkWebhookTest extends Base
$this->assertEquals(2, $p->create(array('name' => 'test2', 'identifier' => 'TEST1')));
// Empty payload
$this->assertFalse($w->parsePayload(array()));
$this->assertFalse($w->receiveEmail(array()));
// Unknown user
$this->assertFalse($w->parsePayload(array('From' => 'a@b.c', 'Subject' => 'Email task', 'MailboxHash' => 'foobar', 'TextBody' => 'boo')));
$this->assertFalse($w->receiveEmail(array('From' => 'a@b.c', 'Subject' => 'Email task', 'MailboxHash' => 'foobar', 'TextBody' => 'boo')));
// Project not found
$this->assertFalse($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test', 'TextBody' => 'boo')));
$this->assertFalse($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test', 'TextBody' => 'boo')));
// User is not member
$this->assertFalse($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo')));
$this->assertFalse($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo')));
$this->assertTrue($pp->addMember(2, 2));
// The task must be created
$this->assertTrue($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo')));
$this->assertTrue($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo')));
$task = $tf->getById(1);
$this->assertNotEmpty($task);
@@ -51,7 +74,7 @@ class PostmarkWebhookTest extends Base
public function testHtml2Markdown()
{
$w = new PostmarkWebhook($this->container);
$w = new Postmark($this->container);
$p = new Project($this->container);
$pp = new ProjectPermission($this->container);
$u = new User($this->container);
@@ -62,7 +85,7 @@ class PostmarkWebhookTest extends Base
$this->assertEquals(1, $p->create(array('name' => 'test2', 'identifier' => 'TEST1')));
$this->assertTrue($pp->addMember(1, 2));
$this->assertTrue($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo', 'HtmlBody' => '<p><strong>boo</strong></p>')));
$this->assertTrue($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => 'boo', 'HtmlBody' => '<p><strong>boo</strong></p>')));
$task = $tf->getById(1);
$this->assertNotEmpty($task);
@@ -71,7 +94,7 @@ class PostmarkWebhookTest extends Base
$this->assertEquals('**boo**', $task['description']);
$this->assertEquals(2, $task['creator_id']);
$this->assertTrue($w->parsePayload(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => '**boo**', 'HtmlBody' => '')));
$this->assertTrue($w->receiveEmail(array('From' => 'me@localhost', 'Subject' => 'Email task', 'MailboxHash' => 'test1', 'TextBody' => '**boo**', 'HtmlBody' => '')));
$task = $tf->getById(2);
$this->assertNotEmpty($task);