diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf76f7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config.php +vendor/ +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index f9c509c..10c76af 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,32 @@ # mail2deck Provides an "email in" solution for the Nextcloud Deck app -## A. For users +# 🚀 A. For users Follow the above steps to add a new card from email. * Deck Bot is the user who will create the cards and it will be set up by your nextcloud admin. * In this tutorial email address for Deck Bot will be: bot@ncserver.com -### 1) Assign Deck Bot to the board. -### 2) Mail subject & content +## 1) Assign Deck Bot to the board. +Deck Bot must be assigned and must have edit permission inside the board. + +## 2) Mail subject & content Let's assume you want to add a card with title "Update website logo" on board "Website" and stack "To do". You can do this in two ways. -#### 2.1: Set stack and board in the email subject +### 2.1: Set stack and board in the email subject Here's how the email subject should look like: -Update website logo b-'Website' s-'To do' +Update website logo b-'website' s-'to do' -*You can use single or double quotes.* +* *You can use single or double quotes.* -#### 2.2: Set the board in the email address -At the end of the email address prefix (before @) add "+Website" +* *Case-insensitive for board and stack respectively.* -Example: bot+Website@ncserver.com +### 2.2: Set the board in the email address +At the end of the email address prefix (before @) add "+website" + +Example: bot+website@ncserver.com + +* *If board has multiple words e.g. "some project", you'll have to send the email to bot+some+project@ncserver.com* In this case, if you don't specify the stack in the email subject, the card will be added in the first stack (if it exists). @@ -28,37 +34,40 @@ Note: * Email content will be card description * You can add attachments in the email and those will be integrated in the created card -## B. For NextCloud admins to setup -### Requirements +# ⚙️ B. For NextCloud admins to setup +## Requirements This app requires php-curl, php-mbstring ,php-imap and some sort of imap server (e.g. Postfix with Courier). -### NC new user +## NC new user Create a new user from User Management on your NC server, which will have to function as a bot. We chose to call him *deckbot*, but you can call it however you want.
__Note__: that you have to assign *deckbot* on each board you want to add new cards from email. -### Configure Email -#### Option 1 - Set up Postfix for incoming email +## Configure Email +### Option 1 - Set up Postfix for incoming email You can setup Posfix mail server folowing the instructions on [Posfix setup](https://docs.gitlab.com/ee/administration/reply_by_email_postfix_setup.html), and after that add "+" delimiter (which separe the user from the board in the email address) using the command:
``` sudo postconf -e "recipient_delimiter = +" ``` -#### Option 2 - Use an existing email server +### Option 2 - Use an existing email server This could be any hosted email service. The only requirement is that you can connect to it via the IMAP protocol. *Please note this option may not be as flexible as a self-hosted server. For example your email service may not support the "+"delimiter for directing messages to a specific board.* -### Download and install +## Download and install If using a self-hosted Postfix server, clone this repository into the home directory of the *incoming* user. If not self-hosting, you may need to create a new user on your system and adjust the commands in future steps to match that username.
``` -cd /home/incoming/ -git clone https://github.com/putt1ck/mail2deck.git mail2deck +su - incoming +git clone https://github.com/newroco/mail2deck.git mail2deck ``` -Edit the config file as you need: +Create config.php file and edit it for your needs: ``` -sudo nano /home/incoming/mail2deck/config.php +cd /home/incoming/mail2deck +cp config.example.php config.php +sudo vim config.php ``` *You can refer to https://www.php.net/manual/en/function.imap-open.php for setting the value of MAIL_SERVER_FLAGS* -### Add a cronjob which will run mail2deck. +## Add a cronjob which will run mail2deck. ``` sudo crontab -u incoming -e ``` Add the following line in the opened file: */5 * * * * /usr/bin/php /home/incoming/mail2deck/index.php >/dev/null 2>&1 -### Finish + +## Finish Now __mail2deck__ will add new cards every five minutes if new emails are received. diff --git a/attachments/attachment.txt b/attachments/attachment.txt deleted file mode 100644 index e69de29..0000000 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5efac28 --- /dev/null +++ b/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "league/html-to-markdown": "^5.1" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..c693662 --- /dev/null +++ b/composer.lock @@ -0,0 +1,108 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "55d22588d74c4cd2af8b00fa004ab06f", + "packages": [ + { + "name": "league/html-to-markdown", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/html-to-markdown.git", + "reference": "e0fc8cf07bdabbcd3765341ecb50c34c271d64e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/e0fc8cf07bdabbcd3765341ecb50c34c271d64e1", + "reference": "e0fc8cf07bdabbcd3765341ecb50c34c271d64e1", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xml": "*", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "mikehaertl/php-shellcommand": "^1.1.0", + "phpstan/phpstan": "^0.12.99", + "phpunit/phpunit": "^8.5 || ^9.2", + "scrutinizer/ocular": "^1.6", + "unleashedtech/php-coding-standard": "^2.7", + "vimeo/psalm": "^4.22" + }, + "bin": [ + "bin/html-to-markdown" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\HTMLToMarkdown\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + }, + { + "name": "Nick Cernis", + "email": "nick@cern.is", + "homepage": "http://modernnerd.net", + "role": "Original Author" + } + ], + "description": "An HTML-to-markdown conversion helper for PHP", + "homepage": "https://github.com/thephpleague/html-to-markdown", + "keywords": [ + "html", + "markdown" + ], + "support": { + "issues": "https://github.com/thephpleague/html-to-markdown/issues", + "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.0" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown", + "type": "tidelift" + } + ], + "time": "2022-03-02T17:24:08+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.1.0" +} diff --git a/config.example.php b/config.example.php new file mode 100644 index 0000000..52c6365 --- /dev/null +++ b/config.example.php @@ -0,0 +1,13 @@ + diff --git a/index.php b/index.php index 9d10a3b..1d8fba6 100644 --- a/index.php +++ b/index.php @@ -1,26 +1,25 @@ getNewMessages(); if ($emails) - for ($j = 0; $j <= count($emails) && $j <= 4; $j++) { - $structure = imap_fetchstructure($inbox, $emails[$j]); + for ($j = 0; $j < count($emails) && $j < 5; $j++) { + $structure = $inbox->fetchMessageStructure($emails[$j]); + $base64encode = false; + if($structure->encoding == 3) { + $base64encode = true; // BASE64 + } $attachments = array(); + $attNames = array(); if (isset($structure->parts) && count($structure->parts)) { for ($i = 0; $i < count($structure->parts); $i++) { - $attachments[$i] = array( - 'is_attachment' => false, - 'filename' => '', - 'name' => '', - 'attachment' => '' - ); - if ($structure->parts[$i]->ifdparameters) { foreach ($structure->parts[$i]->dparameters as $object) { if (strtolower($object->attribute) == 'filename') { @@ -40,7 +39,7 @@ if ($emails) } if ($attachments[$i]['is_attachment']) { - $attachments[$i]['attachment'] = imap_fetchbody($inbox, $emails[$j], $i+1); + $attachments[$i]['attachment'] = $inbox->fetchMessageBody($emails[$j], $i+1); if ($structure->parts[$i]->encoding == 3) { // 3 = BASE64 $attachments[$i]['attachment'] = base64_decode($attachments[$i]['attachment']); } @@ -50,51 +49,66 @@ if ($emails) } } } - for ($i = 1; $i < count($attachments); $i++) { + for ($i = 1; $i <= count($attachments); $i++) { + if(! file_exists(getcwd() . '/attachments')) { + mkdir(getcwd() . '/attachments'); + } if ($attachments[$i]['is_attachment'] == 1) { $filename = $attachments[$i]['name']; if (empty($filename)) $filename = $attachments[$i]['filename']; - $fp = fopen("./attachments/" . $filename, "w+"); + $fp = fopen(getcwd() . '/attachments/' . $filename, "w+"); fwrite($fp, $attachments[$i]['attachment']); fclose($fp); + array_push($attNames, $attachments[$i]['filename']); } } - $hasAttachment = false; - for ($i = 0; $i < count($attachments); $i++) { - if ($attachments[$i]['is_attachment'] != '') { - $hasAttachment = true; - } - } - - $overview = imap_headerinfo($inbox, $emails[$j]); - $toAddress = strrev($overview->toaddress); - if(preg_match('/@([^+]+)/', $toAddress, $m)) { - global $boardName; - $boardName = strrev($m[1]); - } - if ($hasAttachment) { - $message = imap_fetchbody($inbox, $emails[$j], 1.1); + $overview = $inbox->headerInfo($emails[$j]); + $board = null; + if(isset($overview->{'X-Original-To'}) && strstr($overview->{'X-Original-To'}, '+')) { + $board = strstr(substr($overview->{'X-Original-To'}, strpos($overview->{'X-Original-To'}, '+') + 1), '@', true); } else { - $message = imap_fetchbody($inbox, $emails[$j], 1); + if(strstr($overview->to[0]->mailbox, '+')) { + $board = substr($overview->to[0]->mailbox, strpos($overview->to[0]->mailbox, '+') + 1); + } + }; + + if(strstr($board, '+')) $board = str_replace('+', ' ', $board); + + $data = new stdClass(); + $data->title = DECODE_SPECIAL_CHARACTERS ? mb_decode_mimeheader($overview->subject) : $overview->subject; + $data->type = "plain"; + $data->order = -time(); + if(count($attachments)) { + $data->attachments = $attNames; + $description = DECODE_SPECIAL_CHARACTERS ? quoted_printable_decode($inbox->fetchMessageBody($emails[$j], 1.1)) : $inbox->fetchMessageBody($emails[$j], 1.1); + } else { + $description = DECODE_SPECIAL_CHARACTERS ? quoted_printable_decode($inbox->fetchMessageBody($emails[$j], 1)) : $inbox->fetchMessageBody($emails[$j], 1); } - $mailData = new stdClass(); - $mailData->mailSubject = DECODE_SPECIAL_CHARACTERS ? mb_decode_mimeheader($overview->subject) : $overview->subject; - $mailData->mailMessage = DECODE_SPECIAL_CHARACTERS ? quoted_printable_decode($message) : $message; - $mailData->from = $overview->from[0]->mailbox . '@' . $overview->from[0]->host; + if($base64encode) { + $description = base64_decode($description); + } + if($description != strip_tags($description)) { + $description = (new ConvertToMD($description))->execute(); + } + $data->description = $description; + $mailSender = new stdClass(); + $mailSender->userId = $overview->reply_to[0]->mailbox; $newcard = new DeckClass(); - $newcard->getParameters(); - $newcard->addCard($data); + $response = $newcard->addCard($data, $mailSender, $board); + $mailSender->userId .= "@{$overview->reply_to[0]->host}"; - if ($hasAttachment) { - for ($i = 1; $i <= count($attachments); $i++) { - $mailData->fileAttached[$i] = $attachments[$i]['name']; + if(MAIL_NOTIFICATION) { + if($response) { + $inbox->reply($mailSender->userId, $response); + } else { + $inbox->reply($mailSender->userId); } - $newcard->addAttachment($data); + } + if(!$response) { + foreach($attNames as $attachment) unlink(getcwd() . "/attachments/" . $attachment); } } - -imap_close($inbox); ?> diff --git a/lib/ConvertToMD.php b/lib/ConvertToMD.php new file mode 100644 index 0000000..0549e19 --- /dev/null +++ b/lib/ConvertToMD.php @@ -0,0 +1,22 @@ +converter = new HtmlConverter([ + 'strip_tags' => true, + 'remove_nodes' => 'title' + ]); + $this->html = $html; + } + + public function execute() + { + return $this->converter->convert($this->html); + } +} + +?> \ No newline at end of file diff --git a/lib/DeckClass.php b/lib/DeckClass.php index 45d6cc5..802a821 100644 --- a/lib/DeckClass.php +++ b/lib/DeckClass.php @@ -1,117 +1,137 @@ NC_USER . ":" . NC_PASSWORD, + private function apiCall($request, $endpoint, $data = null, $attachment = false){ + $curl = curl_init(); + if($data && !$attachment) { + $endpoint .= '?' . http_build_query($data); + } + curl_setopt_array($curl, array( CURLOPT_URL => $endpoint, CURLOPT_RETURNTRANSFER => true, - CURLOPT_SSLVERSION => "all", - ]; + CURLOPT_ENCODING => '', + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 0, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, + CURLOPT_CUSTOMREQUEST => $request, + CURLOPT_HTTPHEADER => array( + 'Authorization: Basic ' . base64_encode(NC_USER . ':' . NC_PASSWORD), + 'OCS-APIRequest: true', + ), + )); - // set HTTP request specific headers and options/data - if ($request == '') {// an empty request value is used for attachments - // add data without JSON encoding or JSON Content-Type header - $options[CURLOPT_POST] = true; - $options[CURLOPT_POSTFIELDS] = $data; - } elseif ($request == "POST") { - array_push($headers, "Content-Type: application/json"); - $options[CURLOPT_POST] = true; - $options[CURLOPT_POSTFIELDS] = json_encode($data); - } elseif ($request == "GET") { - array_push($headers, "Content-Type: application/json"); - } - - // add headers to options - $options[CURLOPT_HTTPHEADER] = $headers; - curl_setopt_array($curl, $options); + if($request === 'POST') curl_setopt($curl, CURLOPT_POSTFIELDS, (array) $data); $response = curl_exec($curl); $err = curl_error($curl); + $this->responseCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); curl_close($curl); - - if ($err) { - echo "cURL Error #:" . $err; - } + if($err) echo "cURL Error #:" . $err; return json_decode($response); } - public function getParameters() {// get the board and the stack - global $mailData; - global $boardId; - - if(preg_match('/b-"([^"]+)"/', $mailData->mailSubject, $m) || preg_match("/b-'([^']+)'/", $mailData->mailSubject, $m)) { - $boardFromMail = $m[1]; - $mailData->mailSubject = str_replace($m[0], '', $mailData->mailSubject); - } - if(preg_match('/s-"([^"]+)"/', $mailData->mailSubject, $m) || preg_match("/s-'([^']+)'/", $mailData->mailSubject, $m)) { + public function getParameters($params, $boardFromMail = null) {// get the board and the stack + if(!$boardFromMail) // if board is not set within the email address, look for board into email subject + if(preg_match('/b-"([^"]+)"/', $params, $m) || preg_match("/b-'([^']+)'/", $params, $m)) { + $boardFromMail = $m[1]; + $params = str_replace($m[0], '', $params); + } + if(preg_match('/s-"([^"]+)"/', $params, $m) || preg_match("/s-'([^']+)'/", $params, $m)) { $stackFromMail = $m[1]; - $mailData->mailSubject = str_replace($m[0], '', $mailData->mailSubject); + $params = str_replace($m[0], '', $params); } - global $boardName; - $boards = $this->apiCall("GET", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards", ''); + $boards = $this->apiCall("GET", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards"); + $boardId = $boardName = null; foreach($boards as $board) { - if($board->title == $boardFromMail || $board->title == $boardName) { + if(strtolower($board->title) == strtolower($boardFromMail)) { + if(!$this->checkBotPermissions($board)) { + return false; + } $boardId = $board->id; + $boardName = $board->title; + break; + } + } + + if($boardId) { + $stacks = $this->apiCall("GET", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/$boardId/stacks"); + foreach($stacks as $key => $stack) + if(strtolower($stack->title) == strtolower($stackFromMail)) { + $stackId = $stack->id; + break; + } + if($key == array_key_last($stacks) && !isset($stackId)) $stackId = $stacks[0]->id; + } else { + return false; + } + + $boardStack = new stdClass(); + $boardStack->board = $boardId; + $boardStack->stack = $stackId; + $boardStack->newTitle = $params; + $boardStack->boardTitle = $boardName; + + return $boardStack; + } + + public function addCard($data, $user, $board = null) { + $params = $this->getParameters($data->title, $board); + + if($params) { + $data->title = $params->newTitle; + $card = $this->apiCall("POST", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/{$params->board}/stacks/{$params->stack}/cards", $data); + $card->board = $params->board; + $card->stack = $params->stack; + + if($this->responseCode == 200) { + if(ASSIGN_SENDER) $this->assignUser($card, $user); + if($data->attachments) $this->addAttachments($card, $data->attachments); + $card->boardTitle = $params->boardTitle; } else { - echo "Board not found\n"; - } - } - - $stacks = $this->apiCall("GET", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/$boardId/stacks", ''); - foreach($stacks as $stack) { - if($stack->title == $stackFromMail) { - global $stackId; - $stackId = $stack->id; - } else if (!is_numeric($stackId)) { - global $stackId; - $stackId = $stacks[0]->id; + return false; } + return $card; } + return false; } - public function addCard($data) { - global $mailData; - global $stackId; - - $data = new stdClass(); - $data->stackId = $stackId; - $data->title = $mailData->mailSubject; - $data->description = -"$mailData->mailMessage -*** -### $mailData->from -"; - $data->type = "plain"; - $data->order = "-" . time(); // put the card to the top - - //create card - $response = $this->apiCall("POST", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/1/stacks/1/cards", $data); - global $cardId; - $cardId = $response->id; - } - - public function addAttachment($data) { - global $mailData; - global $cardId; - $fullPath = getcwd() . "/attachments/"; //get full path to attachments dirctory - - for ($i = 1; $i < count($mailData->fileAttached); $i++) { + private function addAttachments($card, $attachments) { + $fullPath = getcwd() . "/attachments/"; //get full path to attachments directory + for ($i = 0; $i < count($attachments); $i++) { + $file = $fullPath . $attachments[$i]; $data = array( - 'file' => new CURLFile($fullPath . $mailData->fileAttached[$i]) - ); - $this->apiCall("", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/1/stacks/1/cards/$cardId/attachments?type=deck_file", $data); + 'file' => new CURLFile($file) + ); + $this->apiCall("POST", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/{$card->board}/stacks/{$card->stack}/cards/{$card->id}/attachments?type=file", $data, true); + unlink($file); } } + + public function assignUser($card, $mailUser) + { + $board = $this->apiCall("GET", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/{$card->board}"); + $boardUsers = array_map(function ($user) { return $user->uid; }, $board->users); + + foreach($boardUsers as $user) { + if($user === $mailUser->userId) { + $this->apiCall("PUT", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/{$card->board}/stacks/{$card->stack}/cards/{$card->id}/assignUser", $mailUser); + break; + } + } + } + + private function checkBotPermissions($board) { + foreach($board->acl as $acl) + if($acl->participant->uid == NC_USER && $acl->permissionEdit) + return true; + + return false; + } } ?> diff --git a/lib/MailClass.php b/lib/MailClass.php new file mode 100644 index 0000000..f498162 --- /dev/null +++ b/lib/MailClass.php @@ -0,0 +1,77 @@ +inbox = imap_open("{" . MAIL_SERVER . ":" . MAIL_SERVER_PORT . MAIL_SERVER_FLAGS . "}INBOX", MAIL_USER, MAIL_PASSWORD) + or die("can't connect:" . imap_last_error()); + } + + public function __destruct() + { + imap_close($this->inbox); + } + + public function getNewMessages() { + return imap_search($this->inbox, 'UNSEEN'); + } + + public function fetchMessageStructure($email) { + return imap_fetchstructure($this->inbox, $email); + } + + public function fetchMessageBody($email, $section) { + return imap_fetchbody($this->inbox, $email, $section); + } + + public function headerInfo($email) { + $headerInfo = imap_headerinfo($this->inbox, $email); + $additionalHeaderInfo = imap_fetchheader($this->inbox, $email); + $infos = explode("\n", $additionalHeaderInfo); + + foreach($infos as $info) { + $data = explode(":", $info); + if( count($data) == 2 && !isset($head[$data[0]])) { + if(trim($data[0]) === 'X-Original-To') { + $headerInfo->{'X-Original-To'} = trim($data[1]); + break; + } + } + } + + return $headerInfo; + } + + public function reply($sender, $response = null) { + $server = NC_SERVER; + + if(strstr($server, "https://")) { + $server = str_replace('https://', '', $server); + } else if(strstr($server, "http://")) { + $server = str_replace('http://', '', $server); + } + + $headers = array( + 'From' => 'no-reply@' . $server, + 'MIME-Version' => '1.0', + 'Content-Type' => 'text/html' + ); + + if($response) { + $body = "

You created a new issue on board {$response->boardTitle}.

Check out this board}/card/{$response->id}" . "\">link to see your newly created card.

"; + $subject = 'An issue has been reported!'; + } else { + $body = "

There was a problem creating your new card.

Make sure you set up the board correctly.

"; + $subject = "Your issue has not been reported!"; + } + + $message = ""; + $message .= "Mail2Deck response"; + $message .= "$body"; + $message .= ""; + + mail($sender, $subject, $message, $headers); + } +}