├── LICENSE ├── README.md ├── bin └── imapnotify └── package.json /LICENSE: -------------------------------------------------------------------------------- 1 | This software is released under the MIT license: 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # imapnotify 2 | 3 | Execute scripts on IMAP mailbox changes (new/deleted/updated messages) using IDLE. 4 | 5 | # config 6 | 7 | ```javascript 8 | { 9 | "host": "", 10 | "port": , 11 | "tls": true, 12 | "tlsOptions": { "rejectUnauthorized": false }, 13 | "username": "", 14 | "password": "", 15 | "onNotify": "/usr/bin/mbsync test-%s", 16 | "onNotifyPost": {"mail": "/usr/bin/notmuch new && notify-send 'New mail arrived'"}, 17 | "boxes": 18 | [ 19 | "box1", 20 | "box2/box3" 21 | ] 22 | } 23 | ``` 24 | 25 | ``` 26 | onNotify: 27 | [string]: shell command to run on any event 28 | [object]: shell commands to run on for each type of event 29 | keys: "mail" for new mail, "update" for existing messages updates, "expunge" for messages deletions 30 | onNotifyPost: 31 | [string]: shell command to run after onNotify event 32 | [object]: shell commands to run after onNotify for each type of event 33 | keys: "mail" for new mail, "update" for existing messages updates, "expunge" for messages deletions 34 | ``` 35 | 36 | ### extra options 37 | 38 | ``` 39 | onSIGNAL: a command to run when `SIGNAL` is received. 40 | onSIGNALpost: a command to run after onSINGAL 41 | ``` 42 | 43 | Example: 44 | 45 | ```javascript 46 | { 47 | ... 48 | "onSIGTERM": "/usr/bin/mbsync -a", 49 | "onSIGTERMpost": "echo 'Bye-Bye'" 50 | ... 51 | } 52 | ``` 53 | 54 | # config as a node module 55 | 56 | Since we load the config file with require(), we can get away with any nodejs 57 | module instead of just json. 58 | This allows us to be more flexible with the configuration. 59 | 60 | In particular, you one can use it to load password from a script rather than 61 | having to store it in plain text. 62 | 63 | Important: config-module must be `requirable` (in your $NODE_PATH or 64 | given by an absolute path) 65 | 66 | Using the latest (>0.12) version of nodejs, one can write myconfig.js as 67 | follows. Assuming the script ~/getpass.sh prints out your password. 68 | (execSync is a v0.12 feature) 69 | ```javascript 70 | var child_process = require('child_process'); 71 | 72 | function getStdout(cmd) { 73 | var stdout = child_process.execSync(cmd); 74 | return stdout.toString().trim(); 75 | } 76 | 77 | exports.host = "" 78 | exports.port = ; 79 | exports.tls = true; 80 | exports.tlsOptions = { "rejectUnauthorized": false }; 81 | exports.username = ""; 82 | exports.password = getStdout("~/getpass.sh"); 83 | exports.onNotify = "" 84 | exports.onNotifyPost = "" 85 | exports.boxes = [ "box1", "box2", "some/other/box" ]; 86 | ``` 87 | 88 | Then you can use 89 | 90 | ```bash 91 | $ imapnotify -c ~/.config/imap_notify/myconfig.js 92 | ``` 93 | Thanks Matthew, for pointing that out! 94 | 95 | ## substitutions 96 | 97 | `%s` in `onNotify` and `onNotifyPost` is replaced by the box name. 98 | 99 | `/` symbol (slash) is replaced by `-` symbol (minus) so that 100 | `inbox/junk` becomes `inbox-junk` 101 | 102 | 103 | # example mbsync configuration 104 | 105 | ``` 106 | IMAPAccount example 107 | Host imap.example.com 108 | User test@example.com 109 | Pass secret 110 | UseIMAPS yes 111 | 112 | IMAPStore example-remote 113 | Account example 114 | 115 | MaildirStore example-local 116 | Path ~/Maildir/mail/example/ 117 | Inbox ~/Maildir/mail/example/Inbox 118 | Flatten . 119 | 120 | 121 | Channel example-inbox 122 | Master :example-remote:INBOX 123 | Slave :example-local:INBOX 124 | 125 | Channel example-github 126 | Master :example-remote:github 127 | Slave :example-local:github 128 | 129 | Channel example-lists-vim-announce 130 | Master :example-remote:lists/vim-announce 131 | Slave :example-local:lists/vim-announce 132 | ``` 133 | 134 | # install 135 | 136 | npm: 137 | 138 | ``` 139 | npm install -g imapnotify 140 | ``` 141 | 142 | archlinux aur package: 143 | 144 | ``` 145 | yaourt -S nodejs-imapnotify 146 | ``` 147 | (bonus: systemd.service file) 148 | 149 | # usage 150 | 151 | ``` 152 | imapnotify -c /path/to/config 153 | ``` 154 | -------------------------------------------------------------------------------- /bin/imapnotify: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var Imap = require('imap') 4 | , argv = require('optimist').argv 5 | , path = require('path') 6 | , fs = require('fs') 7 | , printf = require('printf') 8 | , exec = require('child_process').exec 9 | , mkdirp = require('mkdirp') 10 | , xenvar = require('xenvar') 11 | , bunyan = require('bunyan') 12 | ; 13 | 14 | 15 | if (process.env['DEBUG']) { 16 | var logger = bunyan.createLogger({ 17 | name: 'imap_inotify', 18 | stream: process.stdout, 19 | level: 'debug' 20 | }); 21 | } else { 22 | var logger = bunyan.createLogger({ 23 | name: 'imap_inotify', 24 | stream: process.stdout, 25 | level: 'info' 26 | }); 27 | } 28 | 29 | function Notifier(config) { 30 | var self = this; 31 | 32 | this.config = config 33 | this.connections = {} 34 | this.errors = {} 35 | this.logger = logger.child({'server': this.config.server}) 36 | } 37 | 38 | Notifier.prototype.add_box = function(box, cb, debug) { 39 | if (typeof cb !== 'function') { 40 | cb = function() {} 41 | } 42 | var self = this; 43 | 44 | if ( self.connections[box] ) { 45 | delete self.connections[box] 46 | } 47 | 48 | var connection = self.create_connection(debug); 49 | self.connections[box] = connection; 50 | 51 | connection.once('ready', function() { 52 | delete self.errors[box] 53 | self.logger.info({box: box}, 'Connected to server') 54 | self.logger.info({box: box}, 'Selecting box'); 55 | 56 | var delimiter = '/'; 57 | if (connection.namespaces) { 58 | delimiter = connection.namespaces.personal[0].delimiter; 59 | } 60 | 61 | connection.openBox(replace(box, '/', delimiter), true, function(err) { 62 | if (err) throw err; 63 | function addConnectionListener(event, message) { 64 | connection.on(event, function() { 65 | self.logger.info({box: box}, message) 66 | cb(box, event) 67 | }) 68 | } 69 | addConnectionListener('mail', 'New Mail') 70 | addConnectionListener('expunge', 'Deleted Mail') 71 | addConnectionListener('update', 'Altered message metadata') 72 | }); 73 | }); 74 | 75 | connection.once('end', function() { 76 | self.logger.info({box: box}, 'Connection ended') 77 | connection.emit('shouldReconnect') 78 | }); 79 | 80 | connection.once('error', function(error) { 81 | self.logger.error({box: box}, 'Error registered'); 82 | self.logger.error({box: box}, error); 83 | if (! self.errors[box]) { 84 | self.logger.info({box: box}, 'Restarting immediately') 85 | self.errors[box] = {'time': Date.now(), 'count': 1 } 86 | connection.emit('shouldReconnect') 87 | } else { 88 | var delta = Date.now() - self.errors[box]['time'] 89 | , errCount = self.errors[box]['count']; 90 | 91 | 92 | if (self.errors[box]['count'] == 3) { 93 | throw Error('Max retry limit reached') 94 | } 95 | 96 | var restartTimeout = errCount*3000 97 | self.errors[box]['count'] = self.errors[box]['count'] + 1 98 | self.logger.info({box: box}, 'Scheduling restart in ' + restartTimeout) 99 | setTimeout(function() { 100 | connection.emit('shouldReconnect') 101 | }, restartTimeout); 102 | 103 | } 104 | }) 105 | 106 | connection.once('shouldReconnect', function() { 107 | self.logger.debug({box: box}, 'shouldReconnect event') 108 | self.logger.debug({box: box}, 'Reading box') 109 | self.add_box(box, executeOnNotify(self.config), process.env['DEBUG'] === 'imap' ? logger.debug : null) 110 | self.start_watch(box) 111 | }) 112 | } 113 | 114 | Notifier.prototype.start_watch = function(box) { 115 | var self = this; 116 | self.connections[box].connect() 117 | } 118 | 119 | Notifier.prototype.stop_watch = function(box) { 120 | var self = this; 121 | self.logger.info({'box': box}, 'Ending connection') 122 | self.connections[box].end() 123 | } 124 | 125 | Notifier.prototype.create_connection = function(debug) { 126 | var self = this; 127 | 128 | var connection = new Imap({ 129 | user: self.config.username, 130 | password: self.config.password, 131 | host: self.config.host, 132 | port: self.config.port, 133 | tls: self.config.tls || false, 134 | debug: debug ? debug : noop, 135 | tlsOptions: self.config.tlsOptions 136 | }); 137 | 138 | 139 | return connection 140 | } 141 | 142 | Notifier.prototype.start = function() { 143 | var self = this; 144 | Object.keys(this.connections).forEach(function(box) { 145 | self.start_watch(box) 146 | }) 147 | } 148 | 149 | Notifier.prototype.stop = function(cb) { 150 | if (typeof cb != 'function') { 151 | cb = noop 152 | } 153 | var self = this; 154 | Object.keys(self.connections).forEach(function(box) { 155 | self.stop_watch(box) 156 | }) 157 | 158 | setTimeout(function() { 159 | self.logger.debug('Calling stop callback') 160 | cb() 161 | }, 500); 162 | } 163 | 164 | Notifier.prototype.restart = function() { 165 | var self = this 166 | this.stop(function() { 167 | self.stop() 168 | }) 169 | } 170 | 171 | 172 | // Utils 173 | 174 | function noop() { 175 | } 176 | 177 | function replace(string, charToReplace, replaceWith) { 178 | return string.replace(new RegExp(charToReplace, 'g'), replaceWith) 179 | } 180 | 181 | function die(retcode, msg) { 182 | logger.error(msg); 183 | process.exit(retcode); 184 | } 185 | 186 | function expandPath(string) { 187 | return xenvar.expandvars(xenvar.expanduser(string)) 188 | } 189 | 190 | function run(cmd, cb) { 191 | if (typeof cb != 'function') { 192 | cb = noop 193 | } 194 | 195 | logger.info('Running ' + cmd); 196 | exec(cmd, function(err, stdout, stderr) { 197 | if (err) { 198 | logger.error('Error running ' + cmd); 199 | logger.error(stderr); 200 | cb(err) 201 | } else { 202 | logger.debug(stdout); 203 | cb(null) 204 | } 205 | }) 206 | } 207 | 208 | function executeCommands(command, postCommand, cb) { 209 | if (! command) { 210 | logger.debug('Did not find command to execute'); 211 | return cb(new Error('Did not find command to execute')) 212 | } 213 | if (typeof cb != 'function') { 214 | cb = noop 215 | } 216 | 217 | logger.debug('Found command: ' + command); 218 | run(command, function(err) { 219 | if (! err && postCommand) { 220 | logger.debug('Found postCommand: ' + postCommand); 221 | run(postCommand, function(err) { 222 | if (! err) { 223 | cb(null) 224 | } else { 225 | cb(err) 226 | } 227 | }) 228 | } else { 229 | cb(err) 230 | } 231 | }) 232 | } 233 | 234 | function executeOnNotify(config) { 235 | var command = config.onNotify || config.onNewMail 236 | , postCommand = config.onNotifyPost || config.onNewMailPost 237 | , commandIsEventMap = (typeof command === 'object') 238 | , postCommandIsEventMap = (typeof postCommand === 'object') 239 | ; 240 | return function notify(box, event) { 241 | var formattedBox = replace(box.toLowerCase(), '/', '-') 242 | , formattedCommand = printf(commandIsEventMap ? command[event] || '': command, formattedBox) 243 | , formattedPostCommand = printf(postCommandIsEventMap ? postCommand[event] || '': command, formattedBox) 244 | return executeCommands(formattedCommand, formattedPostCommand) 245 | } 246 | } 247 | 248 | function runOnSig(signal, config, cb) { 249 | signal = 'SIG' + signal 250 | logger.debug('trying running on' + signal + ' commands') 251 | return executeCommands(config['on' + signal], config['on' + signal + 'post'], cb) 252 | } 253 | 254 | 255 | function main(cb) { 256 | if (typeof cb !== 'function') { 257 | cb = function() {} 258 | } 259 | 260 | var configPath = expandPath(argv.c || argv.config || '~/.config/imap_inotify/config.json') 261 | 262 | if (! fs.existsSync(configPath)) { 263 | die(2, printf('Config file does not exist: %s', configPath)) 264 | } 265 | 266 | try { 267 | var config = require(configPath) 268 | } catch(e) { 269 | die(3, printf('Parsing error: config file must be in JSON format or a valid JS module: %s', e)) 270 | } 271 | 272 | process.setMaxListeners(100); 273 | 274 | 275 | var notifier = new Notifier(config); 276 | 277 | config.boxes.forEach(function (box) { 278 | notifier.add_box(box, executeOnNotify(config), process.env['DEBUG'] === 'imap' ? logger.debug : null) 279 | }) 280 | 281 | process.on('SIGINT', function() { 282 | logger.info('Caught Ctrl-C, exiting...') 283 | notifier.stop(function() { 284 | logger.info('imap-inotify stoped') 285 | runOnSig('INT', config, function() {process.exit(0)}) 286 | }) 287 | }) 288 | 289 | process.on('SIGTERM', function() { 290 | logger.info('Caught SIGTERM, exiting...') 291 | notifier.stop(function() { 292 | logger.info('imap-inotify stoped') 293 | runOnSig('TERM', config, function() {process.exit(2)}) 294 | }) 295 | }) 296 | 297 | process.on('SIGHUP', function() { 298 | logger.info('Caught SIGHUP, restarting...') 299 | runOnSig('HUP', config, function() {notifier.restart()}) 300 | }) 301 | 302 | notifier.start() 303 | 304 | } 305 | 306 | main(function() { 307 | logger.info('imap-inotify started') 308 | }) 309 | 310 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imapnotify", 3 | "version": "0.4.1", 4 | "description": "Execute scripts on new messages using IDLE imap command", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/a-sk/node-imapnotify.git" 8 | }, 9 | "author": { 10 | "name": "Alexandr Skurikhin", 11 | "email": "a@skurih.in" 12 | }, 13 | "bin": "bin/imapnotify", 14 | "dependencies": { 15 | "imap": "~0.8.14", 16 | "optimist": "~0.6.1", 17 | "printf": "~0.2.0", 18 | "winston": "~0.8.3", 19 | "mkdirp": "~0.5.0", 20 | "xenvar": "~0.5.1", 21 | "bunyan": "~1.8.5" 22 | }, 23 | "keywords": [ 24 | "imap", 25 | "notify", 26 | "mail", 27 | "email" 28 | ], 29 | "license": "MIT" 30 | } 31 | --------------------------------------------------------------------------------