├── 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 |
--------------------------------------------------------------------------------