├── test └── bot.js ├── .npmignore ├── .editorconfig ├── imap.js ├── .gitignore ├── striptags.js ├── .eslintrc ├── package.json ├── LICENSE ├── helpers.js ├── sample.js ├── README.md └── bot.js /test/bot.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | .editorconfig 3 | .gitignore 4 | test 5 | sample.js 6 | uploads 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [{*.json,*.yml}] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /imap.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Imap = require('imap') 4 | 5 | const PROMISIFIED_METHODS = ['openBox', 'search'] 6 | 7 | const promisify = (fn, self) => (...args) => new Promise((ok, fail) => 8 | fn.call(self, ...args, (err, res) => 9 | err ? fail(err) : ok(res) 10 | ) 11 | ) 12 | 13 | module.exports = options => { 14 | const client = new Imap(options) 15 | PROMISIFIED_METHODS.forEach(prop => { 16 | client[prop + 'P'] = promisify(client[prop], client) 17 | }) 18 | return client 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /striptags.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const strip = require('striptags') 4 | 5 | module.exports = html => { 6 | // make sure it's a string 7 | html = String(html) 8 | 9 | // exclude empty strings 10 | if (!html) return '' 11 | 12 | // ensure new lines for paragraphs 13 | html = html.replace(/<\/p>/g, '
') 14 | 15 | // fix some "<" tags 16 | html = html.replace(/<([^!\/a-z])/gi, '<$1') 17 | 18 | // strip tags 19 | var text = strip(html) 20 | 21 | // remove new line duplicates (we don't care this information) 22 | text = text.replace(/\n+/g, '\n') 23 | 24 | // remove space duplicates 25 | text = text.replace(/[ \t]+/g, ' ') 26 | 27 | // trim 28 | text = text.replace(/^\s*/, '').replace(/\s*$/, '') 29 | 30 | return text 31 | } 32 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [ 2, "tab" ], 4 | "linebreak-style": [ 2, "unix" ], 5 | "no-var": [ 2 ], 6 | "quotes": [ 2, "single" ], 7 | "semi": [ 2, "never" ], 8 | "no-unused-vars": ["warn", { "var": "all" }], 9 | "no-trailing-spaces": [ 2 ] 10 | }, 11 | "env": { 12 | "es6": true, 13 | "node": true, 14 | "browser": true 15 | }, 16 | "parserOptions": { 17 | "sourceType": "module", 18 | "ecmaVersion": 7, 19 | "ecmaFeatures": { 20 | "arrowFunctions": true, 21 | "blockBindings": true, 22 | "classes": true, 23 | "destructuring": true, 24 | "experimentalObjectRestSpread": true, 25 | "jsx": true, 26 | "modules": true, 27 | "objectLiteralShorthandMethods": true, 28 | "spread": true 29 | } 30 | }, 31 | "extends": "eslint:recommended" 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mailbot", 3 | "version": "3.1.1", 4 | "description": "Mail Bot: IMAP client sorting and processing entering emails", 5 | "main": "bot.js", 6 | "scripts": { 7 | "test": "mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/byteclubfr/mailbot.git" 12 | }, 13 | "keywords": [ 14 | "imap", 15 | "bot", 16 | "mail" 17 | ], 18 | "author": "Nicolas Chambrier (http://naholyr.fr/)", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/byteclubfr/mailbot/issues" 22 | }, 23 | "homepage": "https://github.com/byteclubfr/mailbot#readme", 24 | "dependencies": { 25 | "address-rfc2822": "^1.0.1", 26 | "debug": "^2.6.0", 27 | "imap": "^0.8.19", 28 | "mailparser": "^0.6.2", 29 | "striptags": "^3.0.1", 30 | "talon": "^2.3.2" 31 | }, 32 | "devDependencies": { 33 | "chai": "^3.5.0", 34 | "fs-promise": "^1.0.0", 35 | "mocha": "^3.2.0", 36 | "proxyquire": "^1.7.11", 37 | "sinon": "^1.17.7" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 ByteClub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const talon = require('talon') 4 | const address = require('address-rfc2822') 5 | const stripTags = require('./striptags') 6 | const debug = require('debug')('mailbot') 7 | 8 | 9 | // Helper: extract signature from text body 10 | 11 | exports.extractSignature = text => talon.signature.bruteforce.extractSignature(text) 12 | 13 | 14 | // Helper: parse addresses (needed when working with triggerOnHeaders) 15 | 16 | exports.parseAddresses = (headers, { quiet = false } = {}) => { 17 | _parseAddressHeader(headers, 'to', quiet) 18 | _parseAddressHeader(headers, 'cc', quiet) 19 | _parseAddressHeader(headers, 'bcc', quiet) 20 | _parseAddressHeader(headers, 'from', quiet, false) 21 | return headers 22 | } 23 | 24 | const _parseAddressHeader = (headers, field, quiet = false, multiple = true) => { 25 | if (multiple) { 26 | let addresses = headers[field] 27 | if (typeof addresses === 'string') { 28 | addresses = [addresses] 29 | } else if (!addresses) { 30 | addresses = [] 31 | } 32 | headers[field] = addresses.map(address => _parseAddressValue(address, quiet)) 33 | } else { 34 | let value = headers[field] 35 | if (Array.isArray(value)) { 36 | if (value.length === 0) { 37 | headers[field] = null 38 | return 39 | } else if (value.length === 1) { 40 | value = value[0] 41 | } else { 42 | debug('Error parsing address: expecting non-multiple values and got', value) 43 | if (!quiet) { 44 | throw new Error('Non multiple value expected for header "' + field + '"') 45 | } 46 | } 47 | } 48 | headers[field] = _parseAddressValue(value) 49 | } 50 | } 51 | 52 | const _parseAddressValue = (value, quiet = false) => { 53 | let parsed 54 | try { 55 | parsed = address.parse(value)[0] 56 | } catch (err) { 57 | debug('Error parsing address', value, err) 58 | if (quiet) { 59 | parsed = {} 60 | } else { 61 | throw err 62 | } 63 | } 64 | parsed.raw = value 65 | return parsed 66 | } 67 | 68 | 69 | // Helper: strip tags 70 | 71 | exports.stripTags = stripTags 72 | -------------------------------------------------------------------------------- /sample.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * This bot will handle every mail with subject 'upload to PATH' 5 | * Every attachment in those mails will be saved to folder 'SENDER/PATH' 6 | * 7 | * Usage: 8 | * - set environment variables GMAIL_USER and GMAIL_PASSWORD or edit this file 9 | * - run script 10 | * - send mails to user with subject beginning with 'upload to ' 11 | **/ 12 | 13 | const { createBot } = require('./bot') 14 | const { mkdirs, createWriteStream, writeFile } = require('fs-promise') // eslint-disable-line no-unused-vars 15 | const path = require('path') 16 | 17 | const ROOT = process.env.UPLOAD_ROOT || 'uploads' 18 | 19 | 20 | // Returns target path from subject 21 | const trigger = ({ headers }) => { 22 | if (headers.subject.toLowerCase().substring(0, 10) === 'upload to ') { 23 | return headers.subject.substring(10) 24 | } else { 25 | return false 26 | } 27 | } 28 | 29 | // Save attachments to provided path 30 | const mailHandler = ({ from, attachments }, uploadDir) => { 31 | const dir = path.join(ROOT, from[0].address, uploadDir) 32 | return mkdirs(dir).then(() => Promise.all(attachments.map(saveAttachment(dir)))) 33 | } 34 | 35 | /* stream version if you'd like to turn on streamAttachments 36 | const saveAttachment = dir => ({ stream, fileName }) => { 37 | const target = createWriteStream(path.join(dir, fileName)) 38 | stream.pipe(target) 39 | return new Promise((resolve, reject) => { 40 | const onEnd = () => { 41 | stream.removeListener('error', onError) 42 | resolve() 43 | } 44 | const onError = err => { 45 | stream.removeListener('end', onEnd) 46 | reject(err) 47 | } 48 | stream.once('end', onEnd) 49 | stream.once('error', onError) 50 | }) 51 | } 52 | */ 53 | 54 | const saveAttachment = dir => ({ content, fileName }) => { 55 | return writeFile(path.join(dir, fileName), content) 56 | } 57 | 58 | const errorHandler = (err, context /*, mail */) => { 59 | console.error(context, err) // eslint-disable-line no-console 60 | if (context === 'MAIL') { 61 | // TODO send error mail to tell user his file has not been saved 62 | } 63 | } 64 | 65 | const bot = createBot({ 66 | imap: { 67 | user: process.env.GMAIL_USER, 68 | password: process.env.GMAIL_PASSWORD, 69 | host: 'imap.googlemail.com', 70 | port: 993, 71 | keepalive: true, 72 | tls: true, 73 | tlsOptions: { 74 | rejectUnauthorized: false 75 | }, 76 | }, 77 | markSeen: false, 78 | streamAttachments: false, 79 | trigger, 80 | triggerOnHeaders: true, 81 | mailHandler, 82 | errorHandler 83 | }) 84 | 85 | bot.start() 86 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MailBot 2 | 3 | This module will help you build an imap bot reacting to incoming emails. 4 | 5 | You mainly provide two functions: 6 | 7 | * A *trigger* which will be called for each received e-mail and should return a promise of a truthy if mail should trigger some job 8 | * A *mailHandler* which will be called for each *triggered* e-mail and will do its job 9 | 10 | ## Warning: Missing tests 11 | 12 | There are actually no test because I found a bit too time-consuming to mock an Imap server for testing. They will come later. 13 | 14 | ## Installation 15 | 16 | ```sh 17 | npm install --save mailbot 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```js 23 | const { createBot } = require('mailbot') 24 | 25 | const bot = createBot(options) 26 | 27 | bot.start() 28 | ``` 29 | 30 | ### API 31 | 32 | ```js 33 | // Start watching, returns a Promise 34 | bot.start() 35 | 36 | // Stops watching, returns a Promise 37 | // if argument is true, it will destroy the connection immediately 38 | // you're strongly advised to gracefully disconnect by not setting this parameter 39 | bot.stop(destroy = false) 40 | 41 | // Restarts 42 | bot.restart(destroy = false) 43 | 44 | // Update configuration option 45 | // Note: if you update 'imap', 'mailbox', or 'filter', it will restart the bot 46 | // unless said otherwise 47 | bot.configure('imap', connectionInfo, autoRestart = true, destroy = false) 48 | ``` 49 | 50 | ### Options 51 | 52 | ```js 53 | { 54 | 55 | // IMAP configuration, see module "imap" 56 | imap: { 57 | user: "user@gmail.com", 58 | password: "password", 59 | host: "imap.googlemail.com", 60 | port: 993, 61 | keepalive: true, 62 | tls: true, 63 | tlsOptions: { 64 | rejectUnauthorized: false 65 | }, 66 | }, 67 | 68 | // Watched inbox 69 | mailbox: 'INBOX', 70 | 71 | // Should bot mark fetched emails as read? 72 | // If true, you're sure you will never fetch same mail twice even when restarting 73 | // If false, you'll mess with server emails 74 | markSeen: true, 75 | 76 | // Search filter to fetch emails 77 | filter: ['UNSEEN'], 78 | 79 | // Should the trigger be checked when receiving headers, or when body has been parsed? 80 | // If your control depends only on headers (subject, recipient, sender…), you can set it to true 81 | // Warning: in 'headers' phase, headers are not parsed yet and you may need helpers 82 | triggerOnHeaders: false, 83 | 84 | // The trigger: this function is called for each e-mail 85 | // Input: a mail object (if triggerOnHeaders is true, it will only have 'headers' property) 86 | // Output: a value or a promise of a value 87 | // The mail will be "handled" only if final value is truthy 88 | trigger ({ headers }) { 89 | // Example: work with e-mails whose subject is 'BOT: ' 90 | const match = headers.subject.match(/BOT: (.*)$/) 91 | return match && match[1] 92 | }, 93 | 94 | // The mail handler: this is the "job executor" 95 | // Input: a mail object, and the trigger value 96 | mailHandler (mail, trigger) { 97 | console.log({ 98 | subject: mail.headers.subject, 99 | trigger 100 | }) 101 | }, 102 | 103 | // The error handler, called for each error occurring during processes 104 | // As there may be error thrown from very different places, 105 | // the function is called with a "context", which can be one of: 106 | // - 'IMAP_ERROR': global error 107 | // - 'SEARCH': failed searching or fetching mails 108 | // - 'TRIGGER': when trying to calculate trigger result (*) 109 | // - 'MAIL': when trying to handle mail (*) 110 | // (*) in those cases the third parameter will be a complete mail object 111 | // allowing you to access sender, subject, etc… 112 | errorHandler (error, context, mail) { 113 | console.error(context, error) 114 | if (mail) { 115 | sendErrorMailTo(mail.from) 116 | } 117 | }, 118 | 119 | // IMAP client reconnection 120 | autoReconnect: true, 121 | autoReconnectTimeout: 5000, 122 | 123 | // false: attachments contents will be directly accessible as Buffer in 'content' property 124 | // true: attachments will be streamed via 'stream' property 125 | // Note: you can safely set it to false if you use triggerOnHeaders, otherwise you should work with streams 126 | streamAttachments: true, 127 | 128 | // If true, mail.text will not contain signature 129 | // Properties 'textSignature' and 'textOriginal' will be added 130 | removeTextSignature: true, 131 | 132 | // If true, if embedded images are found in signature of mail.html 133 | // they will be dropped from 'attachments' and moved to 'ignoredAttachments' 134 | ignoreAttachmentsInSignature: true, 135 | 136 | // If true, property 'cleanSubject' will contain the subject without all messy prefixes 137 | cleanSubject: true, 138 | 139 | // Set to a strictly positive number to define period (milliseconds) between automatic search 140 | // Note that this delay starts AFTER a batch has been handled 141 | // Any falsey value will disable periodic search 142 | searchPeriod: false, 143 | 144 | } 145 | ``` 146 | 147 | ### Mail objects 148 | 149 | Mail objects are generated by `mailparser` (version 0.x, not the buggy 2.0): see [full description of parsed mail object](https://github.com/andris9/mailparser/tree/v0.6.2#parsed-mail-object). 150 | 151 | Following custom properties are added: 152 | 153 | * ignoredAttachments 154 | * textSignature 155 | * textOriginal 156 | * cleanSubject 157 | 158 | ### Helpers 159 | 160 | #### parse addresses in raw headers 161 | 162 | Working with ``triggerOnHeaders: true`` is interesting for performance purpose, but you get unparsed headers. This function will help you working with `to/cc/bcc` headers: 163 | 164 | ```js 165 | { 166 | triggerOnHeaders: true, 167 | trigger: ({ headers }) => { 168 | console.log(headers.to) // "Bob" 169 | parseAddresses(headers) 170 | console.log(headers.to) // [ { address: 'bob@a.b', raw: '"Bob" ', phrase: '"Bob"' } ] 171 | } 172 | } 173 | ``` 174 | 175 | #### Extract signature from text body 176 | 177 | This function will help you remove signature from e-mail body, using `talon`: 178 | 179 | ```js 180 | const { text, signature } = extractSignature(mail.text) 181 | ``` 182 | 183 | #### Strip HTML tags 184 | 185 | This function will remove any HTML tag from a string, using `striptags` internally: 186 | 187 | ```js 188 | const text = stripTags(mail.html) 189 | ``` 190 | 191 | ### Debugging 192 | 193 | This module uses `debug` internally, and you can enable internal debug messages adding `mailbot` to your environment variable `DEBUG`: 194 | 195 | ```sh 196 | env DEBUG=mailbot node mybot.js 197 | ``` 198 | 199 | ## Full sample 200 | 201 | See `sample.js` in repository: it's a mail bot which will react on every mail which subject starts with 'upload to …'. It will fetch all attachments and save it to `//`. 202 | 203 | This illustrates how you can easily create that type of bot. 204 | -------------------------------------------------------------------------------- /bot.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const imap = require('./imap') 4 | const { parseAddresses, stripTags, extractSignature } = require('./helpers') 5 | const { MailParser } = require('mailparser') // Requires 0.x as 2.x will fail listing all attachments 6 | const debug = require('debug')('mailbot') 7 | 8 | 9 | const MATCH_CID = /|\n|"|'|\s|$).*?>/gi 10 | const REPLACE_CID = '{[CID($1)]}' 11 | const MATCH_CID_TOKENS = /\{\[CID\(.*?\)\]\}/gi 12 | const CID_TOKEN_PREFIX_LEN = REPLACE_CID.indexOf('$1') 13 | const CID_TOKEN_SUFFIX_LEN = REPLACE_CID.length - CID_TOKEN_PREFIX_LEN - 2 14 | const RE_SUBJECT_PREFIX = /^(?:(?:R[eé]f?|Fwd|Forward)[:\.]\s*)* /i 15 | 16 | const createBot = (conf = {}) => { 17 | conf = Object.assign({ 18 | imap: Object.assign({ 19 | // user, 20 | // password, 21 | host: 'imap.googlemail.com', 22 | port: 993, 23 | keepalive: true, 24 | tls: true, 25 | tlsOptions: { 26 | rejectUnauthorized: false, 27 | }, 28 | }, conf.imap), 29 | mailbox: 'INBOX', 30 | filter: ['UNSEEN'], 31 | markSeen: true, 32 | triggerOnHeaders: false, 33 | trigger: mail => false, // eslint-disable-line no-unused-vars 34 | mailHandler: (mail, trigger) => {}, // eslint-disable-line no-unused-vars 35 | errorHandler: (error, context) => console.error('MailBot Error', context, error), // eslint-disable-line no-console 36 | autoReconnect: true, 37 | autoReconnectTimeout: 5000, 38 | streamAttachments: true, 39 | removeTextSignature: true, 40 | ignoreAttachmentsInSignature: true, 41 | cleanSubject: true, 42 | searchPeriod: false, // falsey to disable, otherwise milliseconds period 43 | }, conf) 44 | 45 | // Timeout instance for planned periodic search 46 | // Note that this is bot-wide and not client-wide 47 | // as client can be re-set during bot's life 48 | let searchTimeout = null 49 | 50 | const handleError = (context, mail, uid) => error => { 51 | debug('Error', context, error) 52 | 53 | if (uid) { 54 | // Remove from 'doneUids' 55 | doneUids = doneUids.filter(_uid => _uid !== uid) 56 | // Note: we don't automatically retry later, it could be an option 57 | // Instead, this mail will not be checked again unless marked as unread (depends on options and filters) and a new mail is received 58 | } 59 | 60 | Promise.resolve() 61 | .then(() => conf.errorHandler(error, context, mail)) 62 | .catch(err => console.error('MAILBOT: ErrorHandler Error!', context, err)) // eslint-disable-line no-console 63 | } 64 | 65 | const handleMail = (mail, triggerResult, uid) => { 66 | Promise.resolve() 67 | .then(() => formatMail(mail)) 68 | .then(() => conf.mailHandler(mail, triggerResult)) 69 | .catch(handleError('MAIL', mail, uid)) 70 | } 71 | 72 | // Reformat mail: ignore images embedded in signature, extract text signature, etc… 73 | const formatMail = mail => { 74 | // Extract text signature 75 | if (conf.removeTextSignature && mail.text) { 76 | const extract = extractSignature(mail.text) 77 | mail.textOriginal = mail.text 78 | mail.textSignature = extract.signature 79 | mail.text = extract.text 80 | debug('Extracted text signature') 81 | } else { 82 | mail.textOriginal = null 83 | mail.textSignature = null 84 | } 85 | // Ignore attachments embedded in signature 86 | mail.ignoredAttachments = [] 87 | if (conf.ignoreAttachmentsInSignature && mail.html && mail.attachments) { 88 | // Replace IMG tags with CID by a token to not lose them when stripping tags 89 | const html = mail.html.replace(MATCH_CID, REPLACE_CID) 90 | const text = stripTags(html) 91 | const extract = extractSignature(text) 92 | if (extract && extract.signature) { 93 | // Extract CID tokens from signature 94 | const found = extract.signature.match(MATCH_CID_TOKENS) || [] 95 | const cids = found.map(token => token.substring(CID_TOKEN_PREFIX_LEN, token.length - CID_TOKEN_SUFFIX_LEN)) 96 | const { kept, ignored } = mail.attachments.reduce((result, attachment) => { 97 | if (attachment.contentId && cids.includes(attachment.contentId)) { 98 | result.ignored.push(attachment) 99 | } else { 100 | result.kept.push(attachment) 101 | } 102 | return result 103 | }, { ignored: [], kept: [] }) 104 | debug('Ignored attachments in HTML signature:', ignored.length) 105 | mail.attachments = kept 106 | mail.ignoredAttachments = ignored 107 | } 108 | } 109 | // Cleanup subject 110 | if (conf.cleanSubject) { 111 | mail.cleanSubject = mail.subject.replace(RE_SUBJECT_PREFIX, '') 112 | } else { 113 | mail.cleanSubject = null 114 | } 115 | } 116 | 117 | // Do not fetch same mail multiple times to properly handle incoming emails 118 | let doneUids = [] 119 | 120 | // Current client: 121 | // We replace the instance whenever connection info change 122 | let client = null 123 | let shouldRecreateClient = false 124 | 125 | const initClient = () => { 126 | // Interrupt and re-initialize any pending periodic search 127 | clearTimeout(searchTimeout) 128 | searchTimeout = null 129 | 130 | // Open mailbox 131 | client.once('ready', () => { 132 | debug('IMAP ready') 133 | client.openBoxP(conf.mailbox, false) 134 | .then(() => { 135 | debug('Mailbox open') 136 | return search() 137 | }) 138 | .then(watch, watch) // whatever happened 139 | }) 140 | 141 | const newMailSearch = nb => { 142 | debug('New mail', nb) 143 | search() 144 | } 145 | 146 | const periodicSearch = () => { 147 | debug('Periodic search') 148 | search() 149 | } 150 | 151 | const watch = () => client.on('mail', newMailSearch) 152 | 153 | const search = () => { 154 | // Whenever it comes in the middle of a scheduled search, cancel it 155 | clearTimeout(searchTimeout) 156 | return client.searchP(conf.filter) 157 | .then(uids => { 158 | const newUids = uids.filter(uid => !doneUids.includes(uid)) 159 | debug('Search', newUids) 160 | // Optimistically mark uids as done, this prevents double-triggers if a mail 161 | // is received while we're handling one and option 'markSeen' is not enabled 162 | doneUids = uids 163 | return newUids 164 | }) 165 | .then(fetchAndParse) 166 | .catch(handleError('SEARCH')) 167 | .then(() => { 168 | // Finally, plan a new search if applicable 169 | if (conf.searchPeriod) { 170 | searchTimeout = setTimeout(periodicSearch, conf.searchPeriod) 171 | } 172 | }) 173 | } 174 | 175 | client.on('close', err => { 176 | debug('IMAP disconnected', err) 177 | if (err && conf.autoReconnect) { 178 | debug('Trying to reconnect…') 179 | setTimeout(() => client.connect(), conf.autoReconnectTimeout) 180 | } else { 181 | debug('No reconnection (user close or no autoReconnect)') 182 | } 183 | }) 184 | 185 | client.on('error', handleError('IMAP_ERROR')) 186 | 187 | const fetchAndParse = source => { 188 | debug('Fetch', source) 189 | if (source.length === 0) { 190 | return Promise.resolve([]) 191 | } 192 | const fetcher = client.fetch(source, { 193 | bodies: '', 194 | struct: true, 195 | markSeen: conf.markSeen, 196 | }) 197 | fetcher.on('message', parseMessage) 198 | return new Promise((resolve, reject) => { 199 | fetcher.on('end', resolve) 200 | fetcher.on('error', reject) 201 | }) 202 | } 203 | 204 | } 205 | 206 | const parseMessage = (message, uid) => { 207 | debug('Parse message') 208 | 209 | const parser = new MailParser({ 210 | debug: conf.debugMailParser, 211 | streamAttachments: conf.streamAttachments, 212 | showAttachmentLinks: true, 213 | }) 214 | 215 | // Message stream, so we can interrupt parsing if required 216 | let messageStream = null 217 | 218 | // Result of conf.trigger, testing if mail should trigger handler or not 219 | let triggerResult 220 | if (conf.triggerOnHeaders) { 221 | parser.on('headers', headers => { 222 | triggerResult = Promise.resolve().then(() => conf.trigger({ headers })) 223 | triggerResult.then(result => { 224 | if (result) { 225 | debug('Triggered (on headers)', { result, subject: headers.subject }) 226 | } else { 227 | debug('Not triggered (on headers)', { result, subject: headers.subject }) 228 | debug('Not triggered: Immediately interrupt parsing') 229 | messageStream.pause() 230 | parser.end() 231 | } 232 | return result 233 | }) 234 | .catch(() => {}) // Prevent unhandled rejection, it will be handled later catching triggerResult 235 | }) 236 | } 237 | 238 | // Once mail is ready and parsed… 239 | parser.on('end', mail => { 240 | // …check if it should trigger handler… 241 | if (!conf.triggerOnHeaders) { 242 | triggerResult = Promise.resolve().then(() => conf.trigger(mail)) 243 | triggerResult.then(result => { 244 | if (result) { 245 | debug('Triggered (on end)', { result, subject: mail.subject }) 246 | } else { 247 | debug('Not triggered (on end)', { result, subject: mail.subject }) 248 | } 249 | }) 250 | .catch(() => {}) // Prevent unhandled rejection, it will be handled later catching triggerResult 251 | } 252 | // …and handle it if applicable 253 | triggerResult 254 | .then(result => result && handleMail(mail, result, uid)) 255 | .catch(handleError('TRIGGER', mail, uid)) 256 | }) 257 | 258 | // Stream mail once ready 259 | message.once('body', stream => { 260 | messageStream = stream 261 | stream.pipe(parser) 262 | }) 263 | } 264 | 265 | // Public bot API 266 | return { 267 | 268 | start () { 269 | debug('Connecting…') 270 | if (!client || shouldRecreateClient) { 271 | client = imap(conf.imap) 272 | } 273 | initClient() 274 | client.connect() 275 | return new Promise((resolve, reject) => { 276 | const onReady = () => { 277 | debug('Connected!') 278 | client.removeListener('error', onError) 279 | resolve() 280 | } 281 | const onError = err => { 282 | debug('Connection error!', err) 283 | client.removeListener('ready', onReady) 284 | reject(err) 285 | } 286 | client.once('ready', onReady) 287 | client.once('error', onError) 288 | }) 289 | }, 290 | 291 | stop (destroy = false) { 292 | debug('Stopping (' + (destroy ? 'BRUTAL' : 'graceful') + ')…') 293 | if (destroy) { 294 | console.warn('destroy() should be used with high caution! Use graceful stop to remove this warning and avoid losing data.') // eslint-disable-line no-console 295 | } 296 | client[destroy ? 'destroy' : 'end']() 297 | return new Promise((resolve, reject) => { 298 | const onEnd = () => { 299 | debug('Stopped!') 300 | client.removeListener('error', onError) 301 | resolve() 302 | } 303 | const onError = err => { 304 | debug('Stop error!', err) 305 | client.removeListener('end', onEnd) 306 | reject(err) 307 | } 308 | client.once('end', onEnd) 309 | client.once('error', onError) 310 | }) 311 | }, 312 | 313 | restart (destroy = false) { 314 | return this.stop(destroy).then(() => this.start()) 315 | }, 316 | 317 | configure (option, value, autoRestart = true, destroy = false) { 318 | conf[option] = value 319 | if (autoRestart && (option === 'imap' || option === 'mailbox' || option === 'filter')) { 320 | shouldRecreateClient = true 321 | return this.restart(destroy) 322 | } 323 | return Promise.resolve() 324 | }, 325 | 326 | } 327 | } 328 | 329 | 330 | // Public API 331 | 332 | module.exports = { 333 | 334 | // Main function 335 | createBot, 336 | 337 | // Helpers 338 | parseAddresses, 339 | extractSignature, 340 | stripTags, 341 | 342 | } 343 | --------------------------------------------------------------------------------