Add Sendgrid integration (incoming email handling)

This commit is contained in:
Frederic Guillot 2015-04-19 19:23:42 -04:00
parent ac86c3100a
commit f190be9e2d
37 changed files with 380 additions and 89 deletions

View File

@ -84,6 +84,7 @@ Documentation
- [Task links](docs/task-links.markdown)
- [Transitions](docs/transitions.markdown)
- [Time tracking](docs/time-tracking.markdown)
- [Create tasks by email](docs/create-tasks-by-email.markdown)
#### Working with users
@ -108,6 +109,7 @@ Documentation
- [Gitlab webhooks](docs/gitlab-webhooks.markdown)
- [Hipchat](docs/hipchat.markdown)
- [Mailgun](docs/mailgun.markdown)
- [Sendgrid](docs/sendgrid.markdown)
- [Slack](docs/slack.markdown)
- [Postmark](docs/postmark.markdown)

View File

@ -128,4 +128,18 @@ class Webhook extends Base
echo $this->mailgunWebhook->parsePayload($_POST) ? 'PARSED' : 'IGNORED';
}
/**
* Handle Sendgrid webhooks
*
* @access public
*/
public function sendgrid()
{
if ($this->config->get('webhook_token') !== $this->request->getStringParam('token')) {
$this->response->text('Not Authorized', 401);
}
echo $this->sendgridWebhook->parsePayload($_POST) ? 'PARSED' : 'IGNORED';
}
}

View File

@ -31,4 +31,24 @@ class Tool
fclose($fp);
}
}
/**
* Get the mailbox hash from an email address
*
* @static
* @access public
* @param string $email
* @return string
*/
public static function getMailboxHash($email)
{
if (! strpos($email, '@') || ! strpos($email, '+')) {
return '';
}
list($local_part,) = explode('@', $email);
list(,$identifier) = explode('+', $local_part);
return $identifier;
}
}

View File

@ -3,6 +3,7 @@
namespace Integration;
use HTML_To_Markdown;
use Core\Tool;
/**
* Mailgun Webhook
@ -21,7 +22,7 @@ class MailgunWebhook extends Base
*/
public function parsePayload(array $payload)
{
if (empty($payload['sender']) || empty($payload['subject']) || empty($payload['recipient']) || empty($payload['stripped-text'])) {
if (empty($payload['sender']) || empty($payload['subject']) || empty($payload['recipient'])) {
return false;
}
@ -34,7 +35,7 @@ class MailgunWebhook extends Base
}
// The project must have a short name
$project = $this->project->getByIdentifier($this->getMailboxHash($payload['recipient']));
$project = $this->project->getByIdentifier(Tool::getMailboxHash($payload['recipient']));
if (empty($project)) {
$this->container['logger']->debug('MailgunWebhook: ignored => project not found');
@ -48,12 +49,15 @@ class MailgunWebhook extends Base
}
// Get the Markdown contents
if (empty($payload['stripped-html'])) {
if (! empty($payload['stripped-html'])) {
$markdown = new HTML_To_Markdown($payload['stripped-html'], array('strip_tags' => true));
$description = $markdown->output();
}
else if (! empty($payload['stripped-text'])) {
$description = $payload['stripped-text'];
}
else {
$markdown = new HTML_To_Markdown($payload['stripped-html'], array('strip_tags' => true));
$description = $markdown->output();
$description = '';
}
// Finally, we create the task
@ -64,19 +68,4 @@ class MailgunWebhook extends Base
'creator_id' => $user['id'],
));
}
/**
* Get the project identifier
*
* @access public
* @param string $email
* @return string
*/
public function getMailboxHash($email)
{
list($local_part,) = explode('@', $email);
list(,$identifier) = explode('+', $local_part);
return $identifier;
}
}

View File

@ -21,7 +21,7 @@ class PostmarkWebhook extends Base
*/
public function parsePayload(array $payload)
{
if (empty($payload['From']) || empty($payload['Subject']) || empty($payload['MailboxHash']) || empty($payload['TextBody'])) {
if (empty($payload['From']) || empty($payload['Subject']) || empty($payload['MailboxHash'])) {
return false;
}
@ -48,12 +48,15 @@ class PostmarkWebhook extends Base
}
// Get the Markdown contents
if (empty($payload['HtmlBody'])) {
if (! empty($payload['HtmlBody'])) {
$markdown = new HTML_To_Markdown($payload['HtmlBody'], array('strip_tags' => true));
$description = $markdown->output();
}
else if (! empty($payload['TextBody'])) {
$description = $payload['TextBody'];
}
else {
$markdown = new HTML_To_Markdown($payload['HtmlBody'], array('strip_tags' => true));
$description = $markdown->output();
$description = '';
}
// Finally, we create the task

View File

@ -0,0 +1,74 @@
<?php
namespace Integration;
use HTML_To_Markdown;
use Core\Tool;
/**
* Sendgrid Webhook
*
* @package integration
* @author Frederic Guillot
*/
class SendgridWebhook extends Base
{
/**
* Parse incoming email
*
* @access public
* @param array $payload Incoming email
* @return boolean
*/
public function parsePayload(array $payload)
{
if (empty($payload['envelope']) || empty($payload['subject'])) {
return false;
}
$envelope = json_decode($payload['envelope'], true);
$sender = isset($envelope['to'][0]) ? $envelope['to'][0] : '';
// The user must exists in Kanboard
$user = $this->user->getByEmail($envelope['from']);
if (empty($user)) {
$this->container['logger']->debug('SendgridWebhook: ignored => user not found');
return false;
}
// The project must have a short name
$project = $this->project->getByIdentifier(Tool::getMailboxHash($sender));
if (empty($project)) {
$this->container['logger']->debug('SendgridWebhook: 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('SendgridWebhook: ignored => user is not member of the project');
return false;
}
// Get the Markdown contents
if (! empty($payload['html'])) {
$markdown = new HTML_To_Markdown($payload['html'], array('strip_tags' => true));
$description = $markdown->output();
}
else if (! empty($payload['text'])) {
$description = $payload['text'];
}
else {
$description = '';
}
// Finally, we create the task
return (bool) $this->taskCreation->create(array(
'project_id' => $project['id'],
'title' => $payload['subject'],
'description' => $description,
'creator_id' => $user['id'],
));
}
}

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -866,4 +866,6 @@ return array(
'Help on Postmark integration' => 'Aide sur l\'intégration avec Postmark',
'Mailgun (incoming emails)' => 'Mailgun (emails entrants)',
'Help on Mailgun integration' => 'Aide sur l\'intégration avec Mailgun',
'Sendgrid (incoming emails)' => 'Sendgrid (emails entrants)',
'Help on Sendgrid integration' => 'Aide sur l\'intégration avec Sendgrid',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -864,4 +864,6 @@ return array(
// 'Help on Postmark integration' => '',
// 'Mailgun (incoming emails)' => '',
// 'Help on Mailgun integration' => '',
// 'Sendgrid (incoming emails)' => '',
// 'Help on Sendgrid integration' => '',
);

View File

@ -150,6 +150,10 @@ class User extends Base
*/
public function getByEmail($email)
{
if (empty($email)) {
return false;
}
return $this->db->table(self::TABLE)->eq('email', $email)->findOne();
}

View File

@ -78,6 +78,7 @@ class ClassProvider implements ServiceProviderInterface
'BitbucketWebhook',
'Hipchat',
'MailgunWebhook',
'SendgridWebhook',
'SlackWebhook',
'PostmarkWebhook',
)

View File

@ -12,6 +12,12 @@
<p class="form-help"><a href="http://kanboard.net/documentation/mailgun" target="_blank"><?= t('Help on Mailgun integration') ?></a></p>
</div>
<h3><img src="assets/img/sendgrid-icon.png"/>&nbsp;<?= t('Sendgrid (incoming emails)') ?></h3>
<div class="listing">
<input type="text" class="auto-select" readonly="readonly" value="<?= $this->getCurrentBaseUrl().$this->u('webhook', 'sendgrid', array('token' => $values['webhook_token'])) ?>"/><br/>
<p class="form-help"><a href="http://kanboard.net/documentation/sendgrid" target="_blank"><?= t('Help on Sendgrid integration') ?></a></p>
</div>
<h3><img src="assets/img/postmark-icon.png"/>&nbsp;<?= t('Postmark (incoming emails)') ?></h3>
<div class="listing">
<input type="text" class="auto-select" readonly="readonly" value="<?= $this->getCurrentBaseUrl().$this->u('webhook', 'postmark', array('token' => $values['webhook_token'])) ?>"/><br/>

0
assets/img/chosen-sprite.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 646 B

After

Width:  |  Height:  |  Size: 646 B

0
assets/img/chosen-sprite@2x.png Executable file → Normal file
View File

Before

Width:  |  Height:  |  Size: 872 B

After

Width:  |  Height:  |  Size: 872 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

View File

@ -0,0 +1,44 @@
Create tasks by email
=====================
You can create tasks directly by sending an email.
At the moment, Kanboard is integrated with 3 external services:
- [Mailgun](http://kanboard.net/documentation/mailgun)
- [Sendgrid](http://kanboard.net/documentation/sendgrid)
- [Postmark](http://kanboard.net/documentation/postmark)
These services handle incoming emails without having to configure any SMTP server.
When an email is received, Kanboard receive the message on a specific end-point.
All complicated works are already handled by those services.
Incoming emails workflow
------------------------
1. You send an email to a specific address, by example **something+myproject@inbound.mydomain.tld**
2. Your email is forwarded to the third-party SMTP servers
3. The SMTP provider call the Kanboard webhook with the email in JSON or multipart/form-data formats
4. Kanboard parse the received email and create the task to the right project
Note: New tasks are automatically created in the first column.
Email format
------------
- The local part of the email address must use the plus separator, by example **kanboard+project123**
- The string defined after the plus sign must match a project identifier, by example **project123** is the identifier of the project **Project 123**
- The email subject becomes the task title
- The email body becomes the task description (Markdown format)
Incoming emails can be written in text or HTML formats.
**Kanboard is able to convert simple HTML emails to Markdown**.
Security and requirements
-------------------------
- The Kanboard webhook is protected by a random token
- The sender email address must match a Kanboard user
- The Kanboard project must have a unique identifier, by example **MYPROJECT**
- The Kanboard user must be member of the project

View File

@ -6,34 +6,7 @@ You can use the service [Mailgun](http://www.mailgun.com/) to create tasks direc
This integration works with the inbound email service of Mailgun (routes).
Kanboard use a webhook to handle incoming emails.
Incoming emails workflow
------------------------
1. You send an email to a specific address, by example **something+myproject@inbound.mydomain.tld**
2. Your email is forwarded to Mailgun SMTP servers
3. Mailgun call the Kanboard webhook with the email in JSON format
4. Kanboard parse the received email and create the task to the right project
Note: New tasks are automatically created in the first column.
Email format
------------
- The local part of the email address must use the plus separator, by example **kanboard+project123**
- The string defined after the plus sign must match a project identifier, by example **project123** is the identifier of the project **Project 123**
- The email subject becomes the task title
- The email body becomes the task description (Markdown format)
Incoming emails can be written in text or HTML formats.
**Kanboard is able to convert simple HTML emails to Markdown**.
Security and requirements
-------------------------
- The Kanboard webhook is protected by a random token
- The sender email address must match a Kanboard user
- The Kanboard project must have a unique identifier, by example **MYPROJECT**
- The Kanboard user must be member of the project
The [incoming email workflow is described here](http://kanboard.net/documentation/email-tasks).
Mailgun configuration
---------------------
@ -53,9 +26,3 @@ Kanboard configuration
1. Be sure that your users have an email address in their profiles
2. Assign a project identifier to the desired projects: **Project settings > Edit**
3. Try to send an email to your project
Troubleshootings
----------------
- Test if your route match in the console
- Double-check requirements mentioned above

View File

@ -6,40 +6,15 @@ You can use the service [Postmark](https://postmarkapp.com/) to create tasks dir
This integration works with the inbound email service of Postmark.
Kanboard use a webhook to handle incoming emails.
Incoming emails workflow
------------------------
1. You send an email to a specific address, by example **something+myproject@inbound.mydomain.tld**
2. Your email is forwarded to Postmark SMTP servers
3. Postmark call the Kanboard webhook with the email in JSON format
4. Kanboard parse the received email and create the task to the right project
Note: New tasks are automatically created in the first column.
Email format
------------
- The local part of the email address must use the plus separator, by example **kanboard+project123**
- The string defined after the plus sign must match a project identifier, by example **project123** is the identifier of the project **Project 123**
- The email subject becomes the task title
- The email body becomes the task description (Markdown format)
Incoming emails can be written in text or HTML formats.
**Kanboard is able to convert simple HTML emails to Markdown**.
Security and requirements
-------------------------
- The Kanboard webhook is protected by a random token
- The sender email address (From header) must match a Kanboard user
- The Kanboard project must have a unique identifier, by example **MYPROJECT**
- The Kanboard user must be member of the project
The [incoming email workflow is described here](http://kanboard.net/documentation/email-tasks).
Postmark configuration
----------------------
- Follow the [official documentation about inbound email processing](http://developer.postmarkapp.com/developer-process-configure.html)
- The Kanboard webhook url is displayed in **Settings > Integrations > Postmark**
Just follow the [official documentation about inbound email processing](http://developer.postmarkapp.com/developer-process-configure.html).
Basically, you have to forward your own domain or subdomain to a specific Postmark email address.
The Kanboard webhook url is displayed in **Settings > Integrations > Postmark**
Kanboard configuration
----------------------
@ -52,4 +27,3 @@ Troubleshootings
----------------
- Test the webhook url from the Postmark console, you should have a status code `200 OK`
- Double-check requirements mentioned above

24
docs/sendgrid.markdown Normal file
View File

@ -0,0 +1,24 @@
Sendgrid
========
You can use the service [Sendgrid](https://sendgrid.com/) to create tasks directly by email.
This integration works with the [Parse API of Sendgrid](https://sendgrid.com/docs/API_Reference/Webhooks/parse.html).
Kanboard use a webhook to handle incoming emails.
The [incoming email workflow is described here](http://kanboard.net/documentation/email-tasks).
Sendgrid configuration
----------------------
1. Create a new domain or subdomain (by example **inbound.mydomain.tld**) with a MX record that point to **mx.sendgrid.net**
2. Add your domain and the Kanboard webhook url to [the configuration page in Sendgrid](https://sendgrid.com/developer/reply)
The Kanboard webhook url is displayed in **Settings > Integrations > Sendgrid**
Kanboard configuration
----------------------
1. Be sure that your users have an email address in their profiles
2. Assign a project identifier to the desired projects: **Project settings > Edit**
3. Try to send an email to your project

View File

@ -231,6 +231,9 @@ class ProjectTest extends Base
$this->assertNotEmpty($project);
$this->assertEquals('TEST1', $project['identifier']);
$project = $p->getByIdentifier('');
$this->assertFalse($project);
// Validation rules
$r = $p->validateCreation(array('name' => 'test', 'identifier' => 'TEST1'));
$this->assertFalse($r[0]);

View File

@ -0,0 +1,107 @@
<?php
require_once __DIR__.'/Base.php';
use Integration\SendgridWebhook;
use Model\TaskCreation;
use Model\TaskFinder;
use Model\Project;
use Model\ProjectPermission;
use Model\User;
class SendgridWebhookTest extends Base
{
public function testHandlePayload()
{
$w = new SendgridWebhook($this->container);
$p = new Project($this->container);
$pp = new ProjectPermission($this->container);
$u = new User($this->container);
$tc = new TaskCreation($this->container);
$tf = new TaskFinder($this->container);
$this->assertEquals(2, $u->create(array('name' => 'me', 'email' => 'me@localhost')));
$this->assertEquals(1, $p->create(array('name' => 'test1')));
$this->assertEquals(2, $p->create(array('name' => 'test2', 'identifier' => 'TEST1')));
// Empty payload
$this->assertFalse($w->parsePayload(array()));
// Unknown user
$this->assertFalse($w->parsePayload(array(
'envelope' => '{"to":["a@b.c"],"from":"a.b.c"}',
'subject' => 'Email task'
)));
// Project not found
$this->assertFalse($w->parsePayload(array(
'envelope' => '{"to":["a@b.c"],"from":"me@localhost"}',
'subject' => 'Email task'
)));
// User is not member
$this->assertFalse($w->parsePayload(array(
'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}',
'subject' => 'Email task'
)));
$this->assertTrue($pp->addMember(2, 2));
// The task must be created
$this->assertTrue($w->parsePayload(array(
'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}',
'subject' => 'Email task'
)));
$task = $tf->getById(1);
$this->assertNotEmpty($task);
$this->assertEquals(2, $task['project_id']);
$this->assertEquals('Email task', $task['title']);
$this->assertEquals('', $task['description']);
$this->assertEquals(2, $task['creator_id']);
// Html content
$this->assertTrue($w->parsePayload(array(
'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}',
'subject' => 'Email task',
'html' => '<strong>bold</strong> text',
)));
$task = $tf->getById(2);
$this->assertNotEmpty($task);
$this->assertEquals(2, $task['project_id']);
$this->assertEquals('Email task', $task['title']);
$this->assertEquals('**bold** text', $task['description']);
$this->assertEquals(2, $task['creator_id']);
// Text content
$this->assertTrue($w->parsePayload(array(
'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}',
'subject' => 'Email task',
'text' => '**bold** text',
)));
$task = $tf->getById(3);
$this->assertNotEmpty($task);
$this->assertEquals(2, $task['project_id']);
$this->assertEquals('Email task', $task['title']);
$this->assertEquals('**bold** text', $task['description']);
$this->assertEquals(2, $task['creator_id']);
// Text + html content
$this->assertTrue($w->parsePayload(array(
'envelope' => '{"to":["something+test1@localhost"],"from":"me@localhost"}',
'subject' => 'Email task',
'html' => '<strong>bold</strong> html',
'text' => '**bold** text',
)));
$task = $tf->getById(4);
$this->assertNotEmpty($task);
$this->assertEquals(2, $task['project_id']);
$this->assertEquals('Email task', $task['title']);
$this->assertEquals('**bold** html', $task['description']);
$this->assertEquals(2, $task['creator_id']);
}
}

15
tests/units/ToolTest.php Normal file
View File

@ -0,0 +1,15 @@
<?php
require_once __DIR__.'/Base.php';
use Core\Tool;
class ToolTest extends Base
{
public function testMailboxHash()
{
$this->assertEquals('test1', Tool::getMailboxHash('a+test1@localhost'));
$this->assertEquals('', Tool::getMailboxHash('test1@localhost'));
$this->assertEquals('', Tool::getMailboxHash('test1'));
}
}

View File

@ -10,6 +10,16 @@ use Model\Project;
class UserTest extends Base
{
public function testGetByEmail()
{
$u = new User($this->container);
$this->assertNotFalse($u->create(array('username' => 'user1', 'password' => '123456', 'email' => 'user1@localhost')));
$this->assertNotFalse($u->create(array('username' => 'user2', 'password' => '123456', 'email' => '')));
$this->assertNotEmpty($u->getByEmail('user1@localhost'));
$this->assertEmpty($u->getByEmail(''));
}
public function testPassword()
{
$password = 'test123';