bot@ncserver.com
8 |
9 | ## 1) Assign Deck Bot to the board.
10 | Deck Bot must be assigned and must have edit permission inside the board.
11 |
12 | ## 2) Mail subject & content
13 | Let's assume you want to add a card with title "Update website logo" on board "Website" and stack "To do".
14 | You can do this in two ways.
15 |
16 | ### 2.1: Set stack and board in the email subject
17 | Here's what the email subject should look like:
18 | Update website logo b-'website' s-'to do'
19 |
20 | * *You can use single or double quotes.*
21 |
22 | * *Case-insensitive for board and stack respectively.*
23 |
24 | ### 2.2: Set the board in the email address
25 | At the end of the email address prefix (before @) add "+website"
26 |
27 | Example: bot+website@ncserver.com
28 |
29 | * *If board has multiple words e.g. "some project"
, you'll have to send the email to bot+some+project@ncserver.com
*
30 |
31 | 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).
32 |
33 | Note:
34 | * Email content will be card description
35 | * You can add attachments in the email and those will be integrated in the created card
36 |
37 |
38 | ### 2.3: Specify assignee
39 |
40 | Here's what the email subject should look like:
41 |
42 | `Update website logo b-'website' s-'to do' u-'bob'`
43 |
44 | * *You can use single or double quotes.*
45 | * *Case-insensitive for board, stack and user respectively.*
46 |
47 | ### 2.4: Specify due date
48 | You can use the optional parameter `d-` to add a due date to a card.
49 | Here's what the email subject should look like if you want to set a due date to the card:
50 |
51 | `Update website logo b-'website' s-'to do' u-'bob' d-'2022-08-22T19:29:30+00:00'`
52 |
53 | * *You can use single or double quotes.*
54 |
55 | # ⚙️ B. For NextCloud admins to setup
56 | ## Requirements
57 | This app requires php-curl, php-mbstring ,php-imap and some sort of imap server (e.g. Postfix with Courier).
58 | ## NC new user
59 | Create a new user from User Management on your NC server, which shall to function as a bot to post cards received as mail. We chose to call it *deckbot*, but you can call it whatever you want.*/5 * * * * /usr/bin/php /home/incoming/mail2deck/index.php >/dev/null 2>&1
90 |
91 | ### Docker installation
92 | Clone and edit the config.example.php you find in this repository and move it as config.php
93 | ```
94 | git clone https://github.com/newroco/mail2deck.git mail2deck
95 | cd mail2deck
96 | cp config.example.php config.php
97 | nano config.php
98 | ```
99 |
100 | Build your image locally
101 | ```
102 | docker build -t mail2deck:latest .
103 | ```
104 |
105 | Run the docker image mapping the config.json as volume
106 | ```
107 | docker run -d --name mail2deck mail2deck:latest
108 | ```
109 |
110 | Edit your crontab
111 | ```
112 | crontab -e
113 | ```
114 |
115 | And add this line
116 | ```
117 | */5 * * * * /usr/bin/docker start mail2deck
118 | ```
119 |
120 | ## Finish
121 | Now __mail2deck__ will add new cards every five minutes if new emails are received.
122 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "require": {
3 | "league/html-to-markdown": "^5.1"
4 | },
5 | "autoload": {
6 | "psr-4": {
7 | "Mail2Deck\\": "lib/"
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/composer.lock:
--------------------------------------------------------------------------------
1 | {
2 | "_readme": [
3 | "This file locks the dependencies of your project to a known state",
4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
5 | "This file is @generated automatically"
6 | ],
7 | "content-hash": "55d22588d74c4cd2af8b00fa004ab06f",
8 | "packages": [
9 | {
10 | "name": "league/html-to-markdown",
11 | "version": "5.1.0",
12 | "source": {
13 | "type": "git",
14 | "url": "https://github.com/thephpleague/html-to-markdown.git",
15 | "reference": "e0fc8cf07bdabbcd3765341ecb50c34c271d64e1"
16 | },
17 | "dist": {
18 | "type": "zip",
19 | "url": "https://api.github.com/repos/thephpleague/html-to-markdown/zipball/e0fc8cf07bdabbcd3765341ecb50c34c271d64e1",
20 | "reference": "e0fc8cf07bdabbcd3765341ecb50c34c271d64e1",
21 | "shasum": ""
22 | },
23 | "require": {
24 | "ext-dom": "*",
25 | "ext-xml": "*",
26 | "php": "^7.2.5 || ^8.0"
27 | },
28 | "require-dev": {
29 | "mikehaertl/php-shellcommand": "^1.1.0",
30 | "phpstan/phpstan": "^0.12.99",
31 | "phpunit/phpunit": "^8.5 || ^9.2",
32 | "scrutinizer/ocular": "^1.6",
33 | "unleashedtech/php-coding-standard": "^2.7",
34 | "vimeo/psalm": "^4.22"
35 | },
36 | "bin": [
37 | "bin/html-to-markdown"
38 | ],
39 | "type": "library",
40 | "extra": {
41 | "branch-alias": {
42 | "dev-master": "5.2-dev"
43 | }
44 | },
45 | "autoload": {
46 | "psr-4": {
47 | "League\\HTMLToMarkdown\\": "src/"
48 | }
49 | },
50 | "notification-url": "https://packagist.org/downloads/",
51 | "license": [
52 | "MIT"
53 | ],
54 | "authors": [
55 | {
56 | "name": "Colin O'Dell",
57 | "email": "colinodell@gmail.com",
58 | "homepage": "https://www.colinodell.com",
59 | "role": "Lead Developer"
60 | },
61 | {
62 | "name": "Nick Cernis",
63 | "email": "nick@cern.is",
64 | "homepage": "http://modernnerd.net",
65 | "role": "Original Author"
66 | }
67 | ],
68 | "description": "An HTML-to-markdown conversion helper for PHP",
69 | "homepage": "https://github.com/thephpleague/html-to-markdown",
70 | "keywords": [
71 | "html",
72 | "markdown"
73 | ],
74 | "support": {
75 | "issues": "https://github.com/thephpleague/html-to-markdown/issues",
76 | "source": "https://github.com/thephpleague/html-to-markdown/tree/5.1.0"
77 | },
78 | "funding": [
79 | {
80 | "url": "https://www.colinodell.com/sponsor",
81 | "type": "custom"
82 | },
83 | {
84 | "url": "https://www.paypal.me/colinpodell/10.00",
85 | "type": "custom"
86 | },
87 | {
88 | "url": "https://github.com/colinodell",
89 | "type": "github"
90 | },
91 | {
92 | "url": "https://tidelift.com/funding/github/packagist/league/html-to-markdown",
93 | "type": "tidelift"
94 | }
95 | ],
96 | "time": "2022-03-02T17:24:08+00:00"
97 | }
98 | ],
99 | "packages-dev": [],
100 | "aliases": [],
101 | "minimum-stability": "stable",
102 | "stability-flags": [],
103 | "prefer-stable": false,
104 | "prefer-lowest": false,
105 | "platform": [],
106 | "platform-dev": [],
107 | "plugin-api-version": "2.3.0"
108 | }
109 |
--------------------------------------------------------------------------------
/config.example.php:
--------------------------------------------------------------------------------
1 | /etc/ssmtp/ssmtp.conf <Check out this link to see your newly created card.
'; 7 | $headers = array( 8 | 'From' => 'no-reply@example.com', 9 | 'MIME-Version' => '1.0', 10 | 'Content-Type' => 'text/html' 11 | ); 12 | 13 | mail($to, $subject, $message, $headers); 14 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | getNewMessages(); 12 | 13 | if(!$emails) { 14 | // delete all messages marked for deletion and return 15 | $inbox->expunge(); 16 | return; 17 | } 18 | 19 | for ($j = 0; $j < count($emails) && $j < 5; $j++) { 20 | $structure = $inbox->fetchMessageStructure($emails[$j]); 21 | $base64encode = false; 22 | if($structure->encoding == 3) { 23 | $base64encode = true; // BASE64 24 | } 25 | $attachments = array(); 26 | $attNames = array(); 27 | if (isset($structure->parts) && count($structure->parts)) { 28 | for ($i = 0; $i < count($structure->parts); $i++) { 29 | if ($structure->parts[$i]->ifdparameters) { 30 | foreach ($structure->parts[$i]->dparameters as $object) { 31 | if (strtolower($object->attribute) == 'filename') { 32 | $attachments[$i]['is_attachment'] = true; 33 | $attachments[$i]['filename'] = $object->value; 34 | } 35 | } 36 | } 37 | 38 | if ($structure->parts[$i]->ifparameters) { 39 | foreach ($structure->parts[$i]->parameters as $object) { 40 | if (strtolower($object->attribute) == 'name') { 41 | $attachments[$i]['is_attachment'] = true; 42 | $attachments[$i]['name'] = $object->value; 43 | } 44 | } 45 | } 46 | 47 | if ($attachments[$i]['is_attachment']) { 48 | $attachments[$i]['attachment'] = $inbox->fetchMessageBody($emails[$j], $i+1); 49 | if ($structure->parts[$i]->encoding == 3) { // 3 = BASE64 50 | $attachments[$i]['attachment'] = base64_decode($attachments[$i]['attachment']); 51 | } 52 | elseif ($structure->parts[$i]->encoding == 4) { // 4 = QUOTED-PRINTABLE 53 | $attachments[$i]['attachment'] = quoted_printable_decode($attachments[$i]['attachment']); 54 | } 55 | } 56 | } 57 | } 58 | for ($i = 1; $i <= count($attachments); $i++) { 59 | if(! file_exists(getcwd() . '/attachments')) { 60 | mkdir(getcwd() . '/attachments'); 61 | } 62 | if ($attachments[$i]['is_attachment'] == 1) { 63 | $filename = $attachments[$i]['name']; 64 | if (empty($filename)) $filename = $attachments[$i]['filename']; 65 | 66 | $fp = fopen(getcwd() . '/attachments/' . $filename, "w+"); 67 | fwrite($fp, $attachments[$i]['attachment']); 68 | fclose($fp); 69 | array_push($attNames, $attachments[$i]['filename']); 70 | } 71 | } 72 | 73 | $overview = $inbox->headerInfo($emails[$j]); 74 | $board = null; 75 | if(isset($overview->{'X-Original-To'}) && strstr($overview->{'X-Original-To'}, '+')) { 76 | $board = strstr(substr($overview->{'X-Original-To'}, strpos($overview->{'X-Original-To'}, '+') + 1), '@', true); 77 | } else { 78 | if(strstr($overview->to[0]->mailbox, '+')) { 79 | $board = substr($overview->to[0]->mailbox, strpos($overview->to[0]->mailbox, '+') + 1); 80 | } 81 | }; 82 | 83 | if(strstr($board, '+')) $board = str_replace('+', ' ', $board); 84 | 85 | $data = new stdClass(); 86 | $data->title = DECODE_SPECIAL_CHARACTERS ? mb_decode_mimeheader($overview->subject) : $overview->subject; 87 | $data->type = "plain"; 88 | $data->order = -time(); 89 | $body = $inbox->fetchMessageBody($emails[$j], 1.1); 90 | if ($body == "") { 91 | $body = $inbox->fetchMessageBody($emails[$j], 1); 92 | } 93 | if(count($attachments)) { 94 | $data->attachments = $attNames; 95 | $description = DECODE_SPECIAL_CHARACTERS ? quoted_printable_decode($body) : $body; 96 | } else { 97 | $description = DECODE_SPECIAL_CHARACTERS ? quoted_printable_decode($body) : $body; 98 | } 99 | if($base64encode) { 100 | $description = base64_decode($description); 101 | } 102 | if($description != strip_tags($description)) { 103 | $description = (new ConvertToMD($description))->execute(); 104 | } 105 | $data->description = $description; 106 | $mailSender = new stdClass(); 107 | $mailSender->userId = $overview->reply_to[0]->mailbox; 108 | 109 | $newcard = new DeckClass(); 110 | $response = $newcard->addCard($data, $mailSender, $board); 111 | $mailSender->origin .= "{$overview->reply_to[0]->mailbox}@{$overview->reply_to[0]->host}"; 112 | 113 | if(MAIL_NOTIFICATION) { 114 | if($response) { 115 | $inbox->reply($mailSender->origin, $response); 116 | } else { 117 | $inbox->reply($mailSender->origin); 118 | } 119 | } 120 | if(!$response) { 121 | foreach($attNames as $attachment) unlink(getcwd() . "/attachments/" . $attachment); 122 | } 123 | 124 | //remove email after processing 125 | if(DELETE_MAIL_AFTER_PROCESSING) { 126 | $inbox->delete($emails[$j]); 127 | } 128 | } 129 | ?> 130 | -------------------------------------------------------------------------------- /lib/ConvertToMD.php: -------------------------------------------------------------------------------- 1 | converter = new HtmlConverter([ 12 | 'strip_tags' => true, 13 | 'remove_nodes' => 'title' 14 | ]); 15 | $this->html = $html; 16 | } 17 | 18 | public function execute() 19 | { 20 | return $this->converter->convert($this->html); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/DeckClass.php: -------------------------------------------------------------------------------- 1 | $endpoint, 15 | CURLOPT_RETURNTRANSFER => true, 16 | CURLOPT_ENCODING => '', 17 | CURLOPT_MAXREDIRS => 10, 18 | CURLOPT_TIMEOUT => 0, 19 | CURLOPT_FOLLOWLOCATION => true, 20 | CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, 21 | CURLOPT_CUSTOMREQUEST => $request, 22 | CURLOPT_HTTPHEADER => array( 23 | 'Authorization: Basic ' . base64_encode(NC_USER . ':' . NC_PASSWORD), 24 | 'OCS-APIRequest: true', 25 | ), 26 | )); 27 | 28 | if($request === 'POST') curl_setopt($curl, CURLOPT_POSTFIELDS, (array) $data); 29 | 30 | $response = curl_exec($curl); 31 | $err = curl_error($curl); 32 | $this->responseCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); 33 | 34 | curl_close($curl); 35 | if($err) echo "cURL Error #:" . $err; 36 | 37 | return json_decode($response); 38 | } 39 | 40 | public function getParameters($params, $boardFromMail = null) {// get the board and the stack 41 | if(!$boardFromMail) // if board is not set within the email address, look for board into email subject 42 | if(preg_match('/b-"([^"]+)"/', $params, $m) || preg_match("/b-'([^']+)'/", $params, $m)) { 43 | $boardFromMail = $m[1]; 44 | $params = str_replace($m[0], '', $params); 45 | } 46 | if(preg_match('/s-"([^"]+)"/', $params, $m) || preg_match("/s-'([^']+)'/", $params, $m)) { 47 | $stackFromMail = $m[1]; 48 | $params = str_replace($m[0], '', $params); 49 | } 50 | if(preg_match('/u-"([^"]+)"/', $params, $m) || preg_match("/u-'([^']+)'/", $params, $m)) { 51 | $userFromMail = $m[1]; 52 | $params = str_replace($m[0], '', $params); 53 | } 54 | if(preg_match('/d-"([^"]+)"/', $params, $m) || preg_match("/d-'([^']+)'/", $params, $m)) { 55 | $duedateFromMail = $m[1]; 56 | $params = str_replace($m[0], '', $params); 57 | } 58 | 59 | $boards = $this->apiCall("GET", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards"); 60 | $boardId = $boardName = null; 61 | foreach($boards as $board) { 62 | if(strtolower($board->title) == strtolower($boardFromMail)) { 63 | if(!$this->checkBotPermissions($board)) { 64 | return false; 65 | } 66 | $boardId = $board->id; 67 | $boardName = $board->title; 68 | break; 69 | } 70 | } 71 | 72 | if($boardId) { 73 | $stacks = $this->apiCall("GET", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/$boardId/stacks"); 74 | foreach($stacks as $key => $stack) 75 | if(strtolower($stack->title) == strtolower($stackFromMail)) { 76 | $stackId = $stack->id; 77 | break; 78 | } 79 | if($key == array_key_last($stacks) && !isset($stackId)) $stackId = $stacks[0]->id; 80 | } else { 81 | return false; 82 | } 83 | 84 | $boardStack = new \stdClass(); 85 | $boardStack->board = $boardId; 86 | $boardStack->stack = $stackId; 87 | $boardStack->newTitle = $params; 88 | $boardStack->boardTitle = $boardName; 89 | $boardStack->userId = strtolower($userFromMail); 90 | $boardStack->dueDate = $duedateFromMail; 91 | 92 | 93 | return $boardStack; 94 | } 95 | 96 | public function addCard($data, $user, $board = null) { 97 | $params = $this->getParameters($data->title, $board); 98 | 99 | if($params) { 100 | $data->title = $params->newTitle; 101 | $data->duedate = $params->dueDate; 102 | $card = $this->apiCall("POST", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/{$params->board}/stacks/{$params->stack}/cards", $data); 103 | $card->board = $params->board; 104 | $card->stack = $params->stack; 105 | 106 | if ($params->userId) $user->userId = $params->userId; 107 | 108 | if($this->responseCode == 200) { 109 | if(ASSIGN_SENDER || $params->userId) $this->assignUser($card, $user); 110 | if($data->attachments) $this->addAttachments($card, $data->attachments); 111 | $card->boardTitle = $params->boardTitle; 112 | } else { 113 | return false; 114 | } 115 | return $card; 116 | } 117 | return false; 118 | } 119 | 120 | private function addAttachments($card, $attachments) { 121 | $fullPath = getcwd() . "/attachments/"; //get full path to attachments directory 122 | for ($i = 0; $i < count($attachments); $i++) { 123 | $file = $fullPath . $attachments[$i]; 124 | $data = array( 125 | 'file' => new \CURLFile($file) 126 | ); 127 | $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); 128 | unlink($file); 129 | } 130 | } 131 | 132 | public function assignUser($card, $mailUser) 133 | { 134 | $board = $this->apiCall("GET", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/{$card->board}"); 135 | $boardUsers = array_map(function ($user) { return $user->uid; }, $board->users); 136 | 137 | foreach($boardUsers as $user) { 138 | if($user === $mailUser->userId) { 139 | $this->apiCall("PUT", NC_SERVER . "/index.php/apps/deck/api/v1.0/boards/{$card->board}/stacks/{$card->stack}/cards/{$card->id}/assignUser", $mailUser); 140 | break; 141 | } 142 | } 143 | } 144 | 145 | private function checkBotPermissions($board) { 146 | foreach($board->acl as $acl) 147 | if($acl->participant->uid == NC_USER && $acl->permissionEdit) 148 | return true; 149 | 150 | return false; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /lib/MailClass.php: -------------------------------------------------------------------------------- 1 | inbox = imap_open("{" . MAIL_SERVER . ":" . MAIL_SERVER_PORT . MAIL_SERVER_FLAGS . "}INBOX", MAIL_USER, MAIL_PASSWORD) 11 | or die("can't connect:" . imap_last_error()); 12 | } 13 | 14 | public function __destruct() 15 | { 16 | imap_close($this->inbox); 17 | } 18 | 19 | public function getNewMessages() { 20 | return imap_search($this->inbox, 'UNSEEN'); 21 | } 22 | 23 | public function fetchMessageStructure($email) { 24 | return imap_fetchstructure($this->inbox, $email); 25 | } 26 | 27 | public function fetchMessageBody($email, $section) { 28 | return imap_fetchbody($this->inbox, $email, $section); 29 | } 30 | 31 | public function headerInfo($email) { 32 | $headerInfo = imap_headerinfo($this->inbox, $email); 33 | $additionalHeaderInfo = imap_fetchheader($this->inbox, $email); 34 | $infos = explode("\n", $additionalHeaderInfo); 35 | 36 | foreach($infos as $info) { 37 | $data = explode(":", $info); 38 | if( count($data) == 2 && !isset($head[$data[0]])) { 39 | if(trim($data[0]) === 'X-Original-To') { 40 | $headerInfo->{'X-Original-To'} = trim($data[1]); 41 | break; 42 | } 43 | } 44 | } 45 | 46 | return $headerInfo; 47 | } 48 | 49 | public function reply($sender, $response = null) { 50 | $server = NC_SERVER; 51 | 52 | if(strstr($server, "https://")) { 53 | $server = str_replace('https://', '', $server); 54 | } else if(strstr($server, "http://")) { 55 | $server = str_replace('http://', '', $server); 56 | } 57 | 58 | $headers = array( 59 | 'From' => 'no-reply@' . $server, 60 | 'MIME-Version' => '1.0', 61 | 'Content-Type' => 'text/html' 62 | ); 63 | 64 | if($response) { 65 | $body = "Check out this board}/card/{$response->id}" . "\">link to see the newly created card.
67 |Card ID is {$response->id}
"; 68 | $subject = 'A new card has been created!'; 69 | } else { 70 | $body = "Make sure the board was setup correctly.
"; 71 | $subject = "A new card could not be created!"; 72 | } 73 | 74 | $message = ""; 75 | $message .= "