diff --git a/imap-probe.js b/imap-probe.js new file mode 100644 index 0000000..30c693f --- /dev/null +++ b/imap-probe.js @@ -0,0 +1,203 @@ +'use strict'; + +exports = module.exports = ImapProbe; + +var assert = require('assert'), + async = require('async'), + debug = require('debug')('imap-probe'), + Imap = require('imap'), + quotedPrintable = require('quoted-printable'); + +// helper function to parse buffer as a multipart +function parseMultipart(buffer, boundary) { + var parts = buffer.split('\r\n'); + + var content = []; + var found = false; + var headers = false; + var consume = false; + var encodingQuotedPrintable = false; + + for (var i = 0; i < parts.length; ++i) { + if (parts[i].indexOf('--' + boundary) === 0) { + // if we get a second boundary but have already found the plain one, stop + if (found) break; + + content = []; + headers = true; + continue; + } + + // check if we have found the plain/text section + if (headers && parts[i].toLowerCase().indexOf('content-type: text/plain') === 0) { + found = true; + continue; + } + + if (headers && parts[i].toLowerCase().indexOf('content-transfer-encoding: quoted-printable') === 0) { + encodingQuotedPrintable = true; + continue; + } + + // we found the headers and an empty newline marks the beginning of the body + if (headers && parts[i] === '') { + headers = false; + consume = true; + continue; + } + + if (consume) { + if (encodingQuotedPrintable) parts[i] = quotedPrintable.decode(parts[i]); + content.push(parts[i]); + } + } + + return content.join('\n'); +} + +function ImapProbe(options) { + assert(options && typeof options === 'object'); + + this._connection = new Imap(options); + this._options = options; +} + +ImapProbe.prototype._fetchMessage = function (seq, callback) { + assert.strictEqual(typeof callback, 'function'); + + var message = { + subject: null, + body: null, + from: null, + to: null, + multipartBoundry: null, + seqno: null + }; + + var f = this._connection.seq.fetch(seq + ':' + seq, { + bodies: ['HEADER.FIELDS (TO)', 'HEADER.FIELDS (FROM)', 'HEADER.FIELDS (SUBJECT)', 'HEADER.FIELDS (CONTENT-TYPE)', 'TEXT'], + struct: true + }); + + f.on('message', function (msg, seqno) { + message.seqno = seqno; + + msg.on('body', function (stream, info) { + var buffer = ''; + + stream.on('data', function (chunk) { + buffer += chunk.toString('utf8'); + }); + + stream.once('end', function () { + if (info.which === 'TEXT') { + message.body = buffer; + } else if (info.which === 'HEADER.FIELDS (SUBJECT)') { + message.subject = Imap.parseHeader(buffer).subject; + } else if (info.which === 'HEADER.FIELDS (FROM)') { + message.from = Imap.parseHeader(buffer).from; + } else if (info.which === 'HEADER.FIELDS (TO)') { + message.to = Imap.parseHeader(buffer).to; + } else if (info.which === 'HEADER.FIELDS (CONTENT-TYPE)') { + if (buffer.indexOf('multipart/alternative') !== -1) { + // extract boundary and remove any " or ' + message.multipartBoundry = buffer.split('boundary=')[1] + .replace(/"([^"]+(?="))"/g, '$1') + .replace(/'([^']+(?='))'/g, '$1') + .replace(/\r\n/g, ''); + } + } + }); + }); + + msg.once('attributes', function (attrs) { + message.attributes = attrs; + }); + + msg.once('end', function () { + if (message.multipartBoundry) { + message.body = parseMultipart(message.body, message.multipartBoundry); + } + }); + }); + + f.once('error', callback); + f.once('end', function () { callback(null, message); }); +}; + +function searchMessage(message, needle) { + assert.strictEqual(typeof message, 'object'); + assert.strictEqual(typeof needle, 'object'); + + debug('searchMessage : %s %s %s %j', message.seqno, message.from, message.to, message.subject); + + if (needle.subject && message.subject[0].match(needle.subject) === null) return false; + if (needle.body && message.body.match(needle.body) === null) return false; + if (needle.to && message.to.match(needle.to) === null) return false; + if (needle.from && message.from.match(needle.from) === null) return false; + + return true; +} + +ImapProbe.prototype._scanBox = function (needle, callback) { + var that = this; + + this._connection.openBox(this._options.mailbox || 'INBOX', !!this._options.readOnly, function (error, box) { + if (error) return callback(error); + + debug('mailbox messages', box.messages); + + // fetch one by one to have consistent seq numbers + var matchedMessage = null; + async.whilst(function cond() { + return box.messages.total > 0; + }, function (iteratorCallback) { + that._fetchMessage(box.messages.total--, function (error, message) { + if (error) return iteratorCallback(error); + + if (!searchMessage(message, needle)) return iteratorCallback(); // continue to next message + + matchedMessage = message; + iteratorCallback(new Error('Found')); + }); + }, function whilstDone(error) { + if (error && error.message !== 'Found') return callback(error); // imap error + + async.series([ + function moveToTash(done) { + if (!matchedMessage || that._options.readOnly) return done(); + + that._connection.seq.move(matchedMessage.seqno, ['Trash'], done); + }, + that._connection.closeBox.bind(that._connection) + ], function () { + if (!matchedMessage) return callback(new Error('Not found')); + + callback(null, matchedMessage); + }); + }); + }); +}; + +ImapProbe.prototype.probe = function (needle, callback) { + var that = this; + + var times = needle.times || 1, interval = needle.interval || 20000; + + this._connection.once('error', callback); + + this._connection.once('end', function() { + debug('Connection ended'); + }); + + this._connection.once('ready', function () { + debug('Connection success'); + + async.retry({ times: times, interval: interval }, that._scanBox.bind(that, needle), function (error, message) { + that._connection.end(); // doesn't take a callback ! + callback(error, message); + }); + }); + + this._connection.connect(); +}; diff --git a/package.json b/package.json index 6a8bf26..2156369 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "mocha": "^2.2.5", "once": "^1.3.3", "postmark": "^1.0.0", + "quoted-printable": "^1.0.0", "readline-sync": "^1.2.19", "safetydance": "0.0.17", "semver": "^4.3.6",