diff --git a/lib/Connection.js b/lib/Connection.js index 600c956..fda9dc0 100644 --- a/lib/Connection.js +++ b/lib/Connection.js @@ -2,10 +2,14 @@ const Promise = require("bluebird"); const defaultValue = require("default-value"); +const asExpression = require("as-expression"); +const matchValue = require("match-value"); +const unreachable = require("@joepie91/unreachable"); const { command, unsafeRaw, already7Bit } = require("./util/command"); const pInterval = require("./util/p-interval"); const createFetchTaskTracker = require("./util/fetch-task"); +const createBoxTreeBuilder = require("./util/box-tree-builder"); var tls = require('tls'), Socket = require('net').Socket, @@ -1234,25 +1238,166 @@ Object.defineProperty(Connection.prototype, 'seq', { get: function() { }; }}); +function createCommandHandlers(rules) { + let untaggedHandlers = new Map(); + let taggedHandlers = new Map(); + + for (let [ types, options ] of Object.entries(rules)) { + let parsedTypes = types + .split(/\s*\s*/) + .map((type) => type.toUpperCase()); + + for (let type of parsedTypes) { + if (options.untagged != null) { + untaggedHandlers.set(type, options.untagged); + } + + if (options.tagged != null) { + taggedHandlers.set(type, options.tagged); + } + } + } + + // REFACTOR: Eventually remove `.call(this` hackery + return { + canHandleUntagged: function (request) { + return untaggedHandlers.has(request.type.toUpperCase()); + }, + canHandleTagged: function (request) { + return taggedHandlers.has(request.type.toUpperCase()); + }, + handleUntagged: function (request, data) { + let handler = untaggedHandlers.get(request.type.toUpperCase()); + return handler.call(this, request, data); + }, + handleTagged: function (request, data) { + let handler = taggedHandlers.get(request.type.toUpperCase()); + return handler.call(this, request, data); + } + }; +} + +let commandHandlers = createCommandHandlers({ + "LIST, XLIST, LSUB": { + untagged: function (request, { payload }) { + if (request.delimiter === undefined) { + request.delimiter = payload.delimiter; + } else { + if (request.boxBuilder == null) { + request.boxBuilder = createBoxTreeBuilder(); + } + + request._curReq.boxBuilder.add(payload); + } + }, + tagged: function (request, _) { + // FIXME: Check request types for correctness + let boxTree = (request.boxBuilder != null) + ? request.boxBuilder.done() + : {}; // No response items were received + + request.legacyArgs.push(boxTree); + request.responseData.boxTree = boxTree; + } + }, + "ID": { + untagged: function (request, { payload }) { + // https://datatracker.ietf.org/doc/html/rfc2971 + // Used for communicating server/client name, version, etc. + request.responseData.serverVersion = payload; + request.legacyArgs.push(payload); + } + } +}); + + + + + // type: type, -// num: num, +// num: num, -- sequence number of the affected nessage, used for FETCH and EXPUNGE only (message-data) and maybe RECENT and EXISTS (mailbox-data)? // textCode: textCode, // text: val -Connection.prototype._resUntagged = function({ type, num, textCode, text: payload }) { +// NOTE: responseData is meant to contain machine-readable data, payload is meant to contain human-readable data, but in practice payload is also often machine-parsed +Connection.prototype._resUntagged = function({ type, num: sequenceNumber, textCode: responseData, text: payload }) { + // console.log("resUntagged", { type, num: sequenceNumber, payload, textCode: responseData }); var i, len, box, destinationKey; - if (type === 'bye') { + if (this._curReq != null && commandHandlers.canHandleUntagged(this._curReq)) { + // FIXME: Include other fields + commandHandlers.handleUntagged.call(this, this._curReq, { sequenceNumber, payload }); + } else if (type === 'bye') { this._sock.end(); } else if (type === 'namespace') { this.namespaces = payload; - } else if (type === 'id') { - this._curReq.cbargs.push(payload); } else if (type === 'capability') { this._caps = payload.map((v) => v.toUpperCase()); } else if (type === 'preauth') { this.state = 'authenticated'; - } else if (type === 'sort' || type === 'thread' || type === 'esearch') { + } else if (type === 'expunge') { + if (this._box) { + if (this._box.messages.total > 0) { + this._box.messages.total -= 1; + } + + this.emit('expunge', sequenceNumber); + } + } else if (type === 'ok') { + if (this.state === 'connected' && !this._curReq) { + this._login(); + } else if (typeof responseData === 'string' && responseData.toUpperCase() === 'ALERT') { + this.emit('alert', payload); + } else if (this._curReq && responseData && (RE_OPENBOX.test(this._curReq.type))) { + // we're opening a mailbox + + if (!this._box) { + this._resetCurrentBox(); + } + + let destinationKey = (responseData.key != null) + ? responseData.key.toUpperCase() + : responseData; + + if (destinationKey === 'UIDVALIDITY') { + this._box.uidvalidity = responseData.val; + } else if (destinationKey === 'UIDNEXT') { + this._box.uidnext = responseData.val; + } else if (destinationKey === 'HIGHESTMODSEQ') { + this._box.highestmodseq = ''+responseData.val; + } else if (destinationKey === 'PERMANENTFLAGS') { + var idx, permFlags, keywords; + this._box.permFlags = permFlags = responseData.val; + + if ((idx = this._box.permFlags.indexOf('\\*')) > -1) { + this._box.newKeywords = true; + permFlags.splice(idx, 1); + } + + this._box.keywords = keywords = permFlags.filter((f) => f[0] !== '\\'); + + for (i = 0, len = keywords.length; i < len; ++i) { + permFlags.splice(permFlags.indexOf(keywords[i]), 1); + } + } else if (destinationKey === 'UIDNOTSTICKY') { + this._box.persistentUIDs = false; + } else if (destinationKey === 'NOMODSEQ') { + this._box.nomodseq = true; + } + } else if (typeof responseData === 'string' && responseData.toUpperCase() === 'UIDVALIDITY') { + this.emit('uidvalidity', payload); + } + } else if (type === "esearch") { + // https://datatracker.ietf.org/doc/html/rfc4731 / https://datatracker.ietf.org/doc/html/rfc7377 + Object.assign(this._curReq.responseData, payload); // Protocol-defined attributes. TODO: Improve the key names for this? Or is there extensibility? + this._curReq.cbargs.push(payload); + } else if (type === "sort") { + // https://datatracker.ietf.org/doc/html/rfc5256 + this._curReq.responseData.UIDs = payload; + this._curReq.cbargs.push(payload); + } else if (type === 'thread') { + // https://datatracker.ietf.org/doc/html/rfc5256 + this._curReq.responseData.threads = payload; // FIXME: Work out the exact format this._curReq.cbargs.push(payload); } else if (type === 'search') { if (payload.results !== undefined) { @@ -1263,23 +1408,24 @@ Connection.prototype._resUntagged = function({ type, num, textCode, text: payloa this._curReq.cbargs.push(payload); } } else if (type === 'quota') { - var cbargs = this._curReq.cbargs; - if (!cbargs.length) { - cbargs.push([]); - } + let responseData = this._curReq.responseData; + if (responseData.quota == null) { responseData.quota = []; }; + responseData.quota.push(payload); + let cbargs = this._curReq.cbargs; + if (cbargs.length === 0) { cbargs.push([]); } cbargs[0].push(payload); } else if (type === 'recent') { if (!this._box && RE_OPENBOX.test(this._curReq.type)) { - this._createCurrentBox(); + this._resetCurrentBox(); } if (this._box) { - this._box.messages.new = num; + this._box.messages.new = sequenceNumber; } } else if (type === 'flags') { if (!this._box && RE_OPENBOX.test(this._curReq.type)) { - this._createCurrentBox(); + this._resetCurrentBox(); } if (this._box) { @@ -1296,124 +1442,17 @@ Connection.prototype._resUntagged = function({ type, num, textCode, text: payloa } } else if (type === 'exists') { if (!this._box && RE_OPENBOX.test(this._curReq.type)) { - this._createCurrentBox(); + this._resetCurrentBox(); } if (this._box) { - var prev = this._box.messages.total, now = num; + var prev = this._box.messages.total, now = sequenceNumber; this._box.messages.total = now; if (now > prev && this.state === 'authenticated') { this._box.messages.new = now - prev; this.emit('mail', this._box.messages.new); } } - } else if (type === 'expunge') { - if (this._box) { - if (this._box.messages.total > 0) { - --this._box.messages.total; - } - - this.emit('expunge', num); - } - } else if (type === 'ok') { - if (this.state === 'connected' && !this._curReq) { - this._login(); - } else if (typeof textCode === 'string' && textCode.toUpperCase() === 'ALERT') { - this.emit('alert', payload); - } - else if (this._curReq - && textCode - && (RE_OPENBOX.test(this._curReq.type))) { - // we're opening a mailbox - - if (!this._box) { - this._createCurrentBox(); - } - - if (textCode.key) { - destinationKey = textCode.key.toUpperCase(); - } else { - destinationKey = textCode; - } - - if (destinationKey === 'UIDVALIDITY') { - this._box.uidvalidity = textCode.val; - } else if (destinationKey === 'UIDNEXT') { - this._box.uidnext = textCode.val; - } else if (destinationKey === 'HIGHESTMODSEQ') { - this._box.highestmodseq = ''+textCode.val; - } else if (destinationKey === 'PERMANENTFLAGS') { - var idx, permFlags, keywords; - this._box.permFlags = permFlags = textCode.val; - - if ((idx = this._box.permFlags.indexOf('\\*')) > -1) { - this._box.newKeywords = true; - permFlags.splice(idx, 1); - } - - this._box.keywords = keywords = permFlags.filter((f) => f[0] !== '\\'); - - for (i = 0, len = keywords.length; i < len; ++i) { - permFlags.splice(permFlags.indexOf(keywords[i]), 1); - } - } else if (destinationKey === 'UIDNOTSTICKY') - this._box.persistentUIDs = false; - else if (destinationKey === 'NOMODSEQ') - this._box.nomodseq = true; - } else if (typeof textCode === 'string' - && textCode.toUpperCase() === 'UIDVALIDITY') - this.emit('uidvalidity', payload); - } else if (type === 'list' || type === 'lsub' || type === 'xlist') { - if (this.delimiter === undefined) { - this.delimiter = payload.delimiter; - } else { - if (this._curReq.cbargs.length === 0) { - this._curReq.cbargs.push({}); - } - - box = { - attribs: payload.flags, - delimiter: payload.delimiter, - children: null, - parent: null - }; - - for (i = 0, len = SPECIAL_USE_ATTRIBUTES.length; i < len; ++i) { - if (box.attribs.indexOf(SPECIAL_USE_ATTRIBUTES[i]) > -1) { - box.special_use_attrib = SPECIAL_USE_ATTRIBUTES[i]; - } - } - - var name = payload.name, - curChildren = this._curReq.cbargs[0]; - - if (box.delimiter) { - var path = name.split(box.delimiter), - parent = null; - - name = path.pop(); - - for (i = 0, len = path.length; i < len; ++i) { - if (!curChildren[path[i]]) { - curChildren[path[i]] = {}; - } - - if (!curChildren[path[i]].children) { - curChildren[path[i]].children = {}; - } - - parent = curChildren[path[i]]; - curChildren = curChildren[path[i]].children; - } - box.parent = parent; - } - - if (curChildren[name]) { - box.children = curChildren[name].children; - } - - curChildren[name] = box; - } } else if (type === 'status') { let attrs = defaultValue(payload.attrs, {}); @@ -1437,57 +1476,59 @@ Connection.prototype._resUntagged = function({ type, num, textCode, text: payloa } else if (type === 'fetch') { if (/^(?:UID )?FETCH/.test(this._curReq.fullcmd)) { // FETCH response sent as result of FETCH request - let task = this._curReq.fetchCache.get(num); + let task = this._curReq.fetchCache.get(sequenceNumber); // FIXME: Refactor, probably make the task itself an event emitter if (task == null) { - task = this._curReq.fetchCache.create(num, this._curReq.fetching.slice()); - this._curReq.bodyEmitter.emit('message', task.emitter, num); + task = this._curReq.fetchCache.create(sequenceNumber, this._curReq.fetching.slice()); + this._curReq.bodyEmitter.emit('message', task.emitter, sequenceNumber); } task.processFetchResponse(payload); } else { // FETCH response sent as result of STORE request or sent unilaterally, // treat them as the same for now for simplicity - this.emit('update', num, payload); + this.emit('update', sequenceNumber, payload); } } }; -Connection.prototype._resTagged = function(info) { - var req = this._curReq; +Connection.prototype._resTagged = function({ type, tagnum, text: payload, textCode: responseCode }) { + // console.log("resTagged", { type, tagnum, payload, textCode: responseCode }); + // REFACTOR: textCode: either just the key, or a {key, val} object + var request = this._curReq; - if (req != null) { + if (request != null) { var err; this._curReq = undefined; - if (info.type === 'no' || info.type === 'bad') { - // TODO: Can info.text be an empty string? - let errorText = defaultValue(info.text, req.oauthError); + if (type === 'no' || type === 'bad') { + // TODO: Can text be an empty string? + let errorText = defaultValue(payload, request.oauthError); err = Object.assign(new Error(errorText), { - type: info.type, - text: info.textCode, + type: type, + text: responseCode, source: "protocol" }); } else if (this._box != null) { - if (req.type === 'EXAMINE' || req.type === 'SELECT') { + if (request.type === 'EXAMINE' || request.type === 'SELECT') { this._box.readOnly = ( - typeof info.textCode === 'string' - && info.textCode.toUpperCase() === 'READ-ONLY' + typeof responseCode === 'string' + && responseCode.toUpperCase() === 'READ-ONLY' ); } // According to RFC 3501, UID commands do not give errors for // non-existant user-supplied UIDs, so give the callback empty results // if we unexpectedly received no untagged responses. - if (RE_UIDCMD_HASRESULTS.test(req.fullcmd) && req.cbargs.length === 0) { - req.cbargs.push([]); + if (RE_UIDCMD_HASRESULTS.test(request.fullcmd) && request.cbargs.length === 0) { + request.cbargs.push([]); } } - if (req.bodyEmitter) { - var bodyEmitter = req.bodyEmitter; + if (request.bodyEmitter != null) { + var bodyEmitter = request.bodyEmitter; if (err) { bodyEmitter.emit('error', err); @@ -1497,19 +1538,48 @@ Connection.prototype._resTagged = function(info) { bodyEmitter.emit('end'); }); } else { - req.cbargs.unshift(err); - - if (info.textCode && info.textCode.key) { - var key = info.textCode.key.toUpperCase(); - if (key === 'APPENDUID') { // [uidvalidity, newUID] - req.cbargs.push(info.textCode.val[1]); - } else if (key === 'COPYUID') { // [uidvalidity, sourceUIDs, destUIDs] - req.cbargs.push(info.textCode.val[2]); + let extraArguments = asExpression(() => { + if (responseCode != null && responseCode.key != null) { + return matchValue(responseCode.key.toUpperCase(), { + // [uidvalidity, newUID] + APPENDUID: [ responseCode.val[1] ], + // [uidvalidity, sourceUIDs, destUIDs] + COPYUID: [ responseCode.val[2] ], + _: [] + }); + } else { + return []; } + }); + + if (responseCode != null && responseCode.key != null) { + // FIXME: This eventually should replace the extraArguments array stuff + matchValue(responseCode.key.toUpperCase(), { + APPENDUID: () => { + request.responseData.newUID = responseCode.val[1]; + }, + COPYUID: () => { + // FIXME: Parsing? Looks like it will be multiple items + request.responseData.destinationUIDs = responseCode.val[2]; + } + }); } - if (req.cb != null) { - req.cb.apply(this, req.cbargs); + if (commandHandlers.canHandleTagged(request)) { + // FIXME: Add other fields with a sensible name + commandHandlers.handleTagged.call(this, request, { payload }); + } + + // console.dir({ done: request.cbargs }, { depth: null, colors: true }); + + if (request.cb2 != null) { + request.cb.apply(this, request.responseData); + } else if (request.cb != null) { + request.cb.apply(this, [ + err, + ... request.cbargs, + ... extraArguments + ]); } } @@ -1528,7 +1598,7 @@ Connection.prototype._resTagged = function(info) { } }; -Connection.prototype._createCurrentBox = function() { +Connection.prototype._resetCurrentBox = function() { this._box = { name: '', flags: [], @@ -1744,25 +1814,31 @@ Connection.prototype._sockWriteAppendData = function(appendData) this._sock.write(CRLF); }; -Connection.prototype._enqueue = function(fullcmd, promote, cb) { +Connection.prototype._enqueue = function(fullcmd, promote, cb, newAPI) { // TODO: Remove variability if (typeof promote === 'function') { cb = promote; promote = false; } - var info = { - type: fullcmd.match(RE_CMD)[1], - fullcmd: fullcmd, - cb: cb, - cbargs: [] - }, - self = this; + var request = { + type: fullcmd.match(RE_CMD)[1], + fullcmd: fullcmd, + cb: (newAPI) ? null : cb, + cb2: (newAPI) ? cb : null, + cbargs: [], + responseData: {} + }; + + // Alias + request.legacyArgs = request.cbargs; + + var self = this; if (promote) { - this._queue.unshift(info); + this._queue.unshift(request); } else { - this._queue.push(info); + this._queue.push(request); } if (!this._curReq @@ -1796,7 +1872,7 @@ Connection.prototype._enqueue2 = function (command, options = {}) { return this._enqueueAsync(string, insertInFront); } else { // TODO: Use `unreachable` - throw new Error(`Must use a command template string`); + throw unreachable(`Must use a command template string`); } }; diff --git a/lib/Parser.js b/lib/Parser.js index 2ec35b6..aa183f4 100644 --- a/lib/Parser.js +++ b/lib/Parser.js @@ -1,3 +1,5 @@ +/* eslint-disable */ /* FIXME */ + var EventEmitter = require('events').EventEmitter, ReadableStream = require('stream').Readable || require('readable-stream').Readable, diff --git a/lib/util/box-tree-builder.js b/lib/util/box-tree-builder.js new file mode 100644 index 0000000..3ec1fe2 --- /dev/null +++ b/lib/util/box-tree-builder.js @@ -0,0 +1,48 @@ +"use strict"; + +const lastItem = require("last-item"); +const createNamedTreeBuilder = require("./named-tree-builder"); + +const specialUseAttributes = new Set([ + '\\All', + '\\Archive', + '\\Drafts', + '\\Flagged', + '\\Important', + '\\Junk', + '\\Sent', + '\\Trash' +]); + +// TODO: Eventually make this progressively updateable so that the results of multiple LIST commands can be combined over time? + +module.exports = function createBoxTreeBuilder() { + let treeBuilder = createNamedTreeBuilder({ + childrenKey: "children", + parentKey: "parent", + treatRootAsParent: false + }); + + return { + add: function (item) { + let { flags, delimiter } = item; + + let path = (delimiter != null) + ? item.name.split(delimiter) + : [ item.name ]; + + treeBuilder.add(path, { + name: lastItem(path), + path: path, + attributes: flags, + delimiter: delimiter, + specialUseAttribute: flags.find((attribute) => specialUseAttributes.has(attribute)), + children: null, + parent: null + }); + }, + done: function () { + return treeBuilder.done().children; + } + }; +}; diff --git a/lib/util/named-tree-builder.js b/lib/util/named-tree-builder.js new file mode 100644 index 0000000..dcd9da4 --- /dev/null +++ b/lib/util/named-tree-builder.js @@ -0,0 +1,80 @@ +"use strict"; + +const defaultValue = require("default-value"); +const asExpression = require("as-expression"); + +// FIXME: Move to stand-alone package, clearly document that it is not order-sensitive + +function ensureObject(object, property) { + if (object[property] == null) { + object[property] = {}; + } +} + +module.exports = function createNamedTreeBuilder(options = {}) { + let parentKey = options.parentKey; + let childrenKey = options.childrenKey; + let treatRootAsParent = defaultValue(options.treatRootAsParent, true); + let root = {}; + let done = false; + + return { + add: function (path, item) { + if (done === true) { + throw new Error(`done() was called on the builder; no further modifications are possible`); + } else { + let lastItem = root; + + for (let i = 0; i < path.length; i++) { + let segment = path[i]; + + // eslint-disable-next-line no-loop-func + let childrenContainer = asExpression(() => { + if (childrenKey != null) { + ensureObject(lastItem, childrenKey); + return lastItem[childrenKey]; + } else { + return lastItem; + } + }); + + ensureObject(childrenContainer, segment); + let child = childrenContainer[segment]; + + let setParent = ( + parentKey != null + && child[parentKey] == null + && (treatRootAsParent || i >= 1) + ); + + if (setParent) { + child[parentKey] = lastItem; + } + + if (i === path.length - 1) { + // Last segment, this is where we want to put the item data + child = childrenContainer[segment] = { + ... item, + ... child + }; + } + + lastItem = child; + } + } + }, + done: function () { + done = true; + return root; + } + }; +}; + +// let builder = module.exports({ childrenKey: "children", parentKey: "parent" }); +// builder.add([], { description: "root" }); +// builder.add(["a"], { description: "root -> a" }); +// builder.add(["b", "c"], { description: "root -> b -> c" }); +// builder.add(["b"], { description: "root -> b" }); +// builder.add(["a", "c"], { description: "root -> a -> c" }); +// builder.add(["a", "c", "d"], { description: "root -> a -> c -> d" }); +// console.dir(builder.done(), { depth: null }); diff --git a/package.json b/package.json index e0a248e..73f0782 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,13 @@ "description": "An IMAP module for node.js that makes communicating with IMAP servers easy", "main": "./lib/Connection", "dependencies": { + "@joepie91/unreachable": "^1.0.0", + "as-expression": "^1.0.0", "bluebird": "^3.7.2", "default-value": "^1.0.0", + "last-item": "^1.0.0", "map-obj": "^4.2.1", + "match-value": "^1.1.0", "p-defer": "^3.0.0", "p-try": "^2.2.0", "readable-stream": "1.1.x", diff --git a/test/reference-tree.js b/test/reference-tree.js new file mode 100644 index 0000000..c8e35cf --- /dev/null +++ b/test/reference-tree.js @@ -0,0 +1,138 @@ +"use strict"; + +// FIXME: Publish as stand-alone `reference-tree` package, clearly document that it is not order-sensitive + +function isObject(value) { + return (value != null && typeof value === "object"); +} + +function isLabelReference(value) { + return (isObject(value) && value.__referenceTree_reference != null); +} + +function isParentReference(value) { + return (isObject(value) && value.__referenceTree_parent != null); +} + +function hasLabel(value) { + return (isObject(value) && value.__referenceTree_label != null); +} + +module.exports = { + build: function buildReferenceTree(object) { + // NOTE: Mutates input object! + let stack = []; + let labelledItems = new Map(); + let seen = new Set(); + let onFirstPass = true; + let doSecondPass = false; + + function handleItem(container, key) { + let value = container[key]; + + if (!seen.has(value)) { + seen.add(value); + + if (hasLabel(value)) { + let label = value.__referenceTree_label; + delete value.__referenceTree_label; + + labelledItems.set(label, value); + } + + if (isLabelReference(value)) { + let label = value.__referenceTree_reference; + + if (labelledItems.has(label)) { + container[key] = labelledItems.get(label); + } else if (onFirstPass) { + // We haven't seen the referenced item yet; this typically happens when either the reference is defined before the label, or when there are cyclical references + doSecondPass = true; + } else { + throw new Error(`Encountered a label '${label}' that doesn't exist anywhere in the tree`); + } + } else if (isParentReference(value)) { + let levels = value.__referenceTree_parent; + + if (levels > stack.length) { + throw new Error(`Tried to access a parent ${levels} steps away, but there are only ${stack.length} parents`); + } else { + let parentIndex = stack.length - levels; + container[key] = stack[parentIndex]; + } + } else { + stack.push(container); + processValue(value); + stack.pop(); + } + } + } + + function processObject(object) { + for (let key of Object.keys(object)) { + handleItem(object, key); + } + } + + function processArray(array) { + for (let i = 0; i < array.length; i++) { + handleItem(array, i); + } + } + + function processValue(value) { + if (Array.isArray(value)) { + return processArray(value); + } else if (isObject(value)) { + return processObject(value); + } + } + + processValue(object); + + if (doSecondPass === true) { + onFirstPass = false; + seen = new Set(); + processValue(object); + } + + return object; + }, + Parent: function (levels = 1) { + return { + __referenceTree_parent: levels + }; + }, + Label: function (label, object) { + object.__referenceTree_label = label; + return object; + }, + Reference: function (label) { + return { + __referenceTree_reference: label + }; + } +}; + +// let { build, Label, Reference, Parent } = module.exports; + +// let result = build({ +// foo: "bar", +// a: Label("A", { +// letter: "A", +// foo: Reference("B") +// }), +// b: Label("B", { +// letter: "B", +// foo: Reference("A"), +// children: [{ +// qux: "quz" +// }, { +// qux: Parent(2), +// quz: Reference("B") +// }] +// }), +// }); + +// console.dir(result, { depth: 4 }); +// console.log(result.b.children[1].qux === result.b.children[1].quz); diff --git a/test/tests/box-tree.js b/test/tests/box-tree.js new file mode 100644 index 0000000..28a376b --- /dev/null +++ b/test/tests/box-tree.js @@ -0,0 +1,313 @@ +"use strict"; + +const tap = require("tap"); +const { build, Parent } = require("../reference-tree"); + +const createBoxTreeBuilder = require("../../lib/util/box-tree-builder"); + +let input = [{ + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'confirmed-spam' +}, { + flags: ['\\HasNoChildren', '\\Trash'], + delimiter: '/', + name: 'Trash' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'SpamLikely' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Spam' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Sent Items' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Archive' +}, { + flags: ['\\HasNoChildren', '\\Drafts'], + delimiter: '/', + name: 'Drafts' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Notes' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'TeamViewer' +}, { + flags: ['\\HasNoChildren', '\\Sent'], + delimiter: '/', + name: 'Sent Messages' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'confirmed-ham' +}, { + flags: ['\\Noselect', '\\HasChildren'], + delimiter: '/', + name: 'Public' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Public/office3' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Public/office4' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Public/support' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Public/root' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Public/updates' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Public/postmaster' +}, { + flags: ['\\Noselect', '\\HasChildren'], + delimiter: '/', + name: 'Shared' +}, { + flags: ['\\Noselect', '\\HasChildren'], + delimiter: '/', + name: 'Shared/d.marteva' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'Shared/d.marteva/INBOX' +}, { + flags: ['\\HasNoChildren'], + delimiter: '/', + name: 'INBOX' +}]; + +let expected = build({ + 'confirmed-spam': { + name: "confirmed-spam", + path: [ "confirmed-spam" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: undefined + }, + Trash: { + name: "Trash", + path: [ "Trash" ], + attributes: ['\\HasNoChildren', '\\Trash'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: '\\Trash' + }, + SpamLikely: { + name: "SpamLikely", + path: [ "SpamLikely" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: undefined + }, + Spam: { + name: "Spam", + path: [ "Spam" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: undefined + }, + 'Sent Items': { + name: "Sent Items", + path: [ "Sent Items" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: undefined + }, + Archive: { + name: "Archive", + path: [ "Archive" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: undefined + }, + Drafts: { + name: "Drafts", + path: [ "Drafts" ], + attributes: ['\\HasNoChildren', '\\Drafts'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: '\\Drafts' + }, + Notes: { + name: "Notes", + path: [ "Notes" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: undefined + }, + TeamViewer: { + name: "TeamViewer", + path: [ "TeamViewer" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: undefined + }, + 'Sent Messages': { + name: "Sent Messages", + path: [ "Sent Messages" ], + attributes: ['\\HasNoChildren', '\\Sent'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: '\\Sent' + }, + 'confirmed-ham': { + name: "confirmed-ham", + path: [ "confirmed-ham" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: undefined + }, + Public: { + name: "Public", + path: [ "Public" ], + attributes: ['\\Noselect', '\\HasChildren'], + delimiter: '/', + children: { + office3: { + name: "office3", + path: [ "Public", "office3" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: Parent(2), + specialUseAttribute: undefined + }, + office4: { + name: "office4", + path: [ "Public", "office4" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: Parent(2), + specialUseAttribute: undefined + }, + support: { + name: "support", + path: [ "Public", "support" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: Parent(2), + specialUseAttribute: undefined + }, + root: { + name: "root", + path: [ "Public", "root" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: Parent(2), + specialUseAttribute: undefined + }, + updates: { + name: "updates", + path: [ "Public", "updates" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: Parent(2), + specialUseAttribute: undefined + }, + postmaster: { + name: "postmaster", + path: [ "Public", "postmaster" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: Parent(2), + specialUseAttribute: undefined + } + }, + parent: null, + specialUseAttribute: undefined + }, + Shared: { + name: "Shared", + path: [ "Shared" ], + attributes: ['\\Noselect', '\\HasChildren'], + delimiter: '/', + children: { + 'd.marteva': { + name: "d.marteva", + path: [ "Shared", "d.marteva" ], + attributes: ['\\Noselect', '\\HasChildren'], + delimiter: '/', + children: { + INBOX: { + name: "INBOX", + path: [ "Shared", "d.marteva", "INBOX" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: Parent(2), + specialUseAttribute: undefined + } + }, + parent: Parent(2), + specialUseAttribute: undefined + } + }, + parent: null, + specialUseAttribute: undefined + }, + INBOX: { + name: "INBOX", + path: [ "INBOX" ], + attributes: ['\\HasNoChildren'], + delimiter: '/', + children: null, + parent: null, + specialUseAttribute: undefined + } +}); + +tap.test("build-box-tree", (test) => { + let builder = createBoxTreeBuilder(); + + for (let item of input) { + builder.add(item); + } + + test.same(builder.done(), expected); + + // Needed to convince node-tap that the test is completed, for some reason... + return Promise.resolve(); +}); diff --git a/test/tests/list.js b/test/tests/list.js new file mode 100644 index 0000000..bbd4db9 --- /dev/null +++ b/test/tests/list.js @@ -0,0 +1,111 @@ +"use strict"; + +const Promise = require("bluebird"); +const tap = require("tap"); +const pEvent = require("p-event"); +const IMAP = require("../../lib/Connection"); + +const lines = require("../lines"); +const createMockServer = require("../mock-server"); + +tap.test("list", (test) => { + return testFetch(test); +}); + +function testFetch(test) { + let steps = [{ + expected: 'A0 CAPABILITY', + response: lines([ + '* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA CHILDREN', + 'A0 OK Thats all she wrote!', + ]), + }, { + expected: 'A1 LOGIN "foo" "bar"', + response: lines([ + '* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA CHILDREN UIDPLUS MOVE', + 'A1 OK authenticated (Success)', + ]), + }, { + expected: 'A2 NAMESPACE', + response: lines([ + '* NAMESPACE (("" "/")) NIL NIL', + 'A2 OK Success', + ]), + }, { + expected: 'A3 LIST "" ""', + response: lines([ + '* LIST (\\HasNoChildren) "/" confirmed-spam', + '* LIST (\\HasNoChildren \\Trash) "/" Trash', + '* LIST (\\HasNoChildren) "/" SpamLikely', + '* LIST (\\HasNoChildren) "/" Spam', + '* LIST (\\HasNoChildren) "/" "Sent Items"', + '* LIST (\\HasNoChildren) "/" Archive', + '* LIST (\\HasNoChildren \\Drafts) "/" Drafts', + '* LIST (\\HasNoChildren) "/" Notes', + '* LIST (\\HasNoChildren) "/" TeamViewer', + '* LIST (\\HasNoChildren \\Sent) "/" "Sent Messages"', + '* LIST (\\HasNoChildren) "/" confirmed-ham', + '* LIST (\\Noselect \\HasChildren) "/" Public', + '* LIST (\\HasNoChildren) "/" Public/office3', + '* LIST (\\HasNoChildren) "/" Public/office4', + '* LIST (\\HasNoChildren) "/" Public/support', + '* LIST (\\HasNoChildren) "/" Public/root', + '* LIST (\\HasNoChildren) "/" Public/updates', + '* LIST (\\HasNoChildren) "/" Public/postmaster', + '* LIST (\\Noselect \\HasChildren) "/" Shared', + '* LIST (\\Noselect \\HasChildren) "/" Shared/d.marteva', + '* LIST (\\HasNoChildren) "/" Shared/d.marteva/INBOX', + '* LIST (\\HasNoChildren) "/" INBOX', + 'A3 OK Success', + ]), + }, { + expected: 'A4 EXAMINE "INBOX"', + response: lines([ + '* FLAGS (\\Answered \\Flagged \\Draft \\Deleted \\Seen)', + '* OK [PERMANENTFLAGS ()] Flags permitted.', + '* OK [UIDVALIDITY 2] UIDs valid.', + '* 685 EXISTS', + '* 0 RECENT', + '* OK [UIDNEXT 4422] Predicted next UID.', + 'A4 OK [READ-ONLY] INBOX selected. (Success)', + ]), + }, { + expected: "A5 LOGOUT", + response: lines([ + '* BYE LOGOUT Requested', + 'A5 OK good day (Success)', + ]), + }]; + + return Promise.try(() => { + return createMockServer({ + steps: steps, + test: test + }); + }).then(({ server, port, finalize }) => { + const client = new IMAP({ + user: "foo", + password: "bar", + host: "127.0.0.1", + port: port, + keepalive: false + }); + + Promise.promisifyAll(client, { multiArgs: true }); + client.connect(); + + return Promise.try(() => { + return pEvent(client, "ready"); + }).then(() => { + server.close(); // Stop listening for new clients + + return client.openBoxAsync("INBOX", true); + }).tap(() => { + client.end(); + + return pEvent(client, "end"); + }).then(() => { + finalize(); + }); + }); +} diff --git a/yarn.lock b/yarn.lock index 48ebbb4..164f1c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -321,6 +321,11 @@ resolved "https://registry.yarnpkg.com/@joepie91/eslint-config/-/eslint-config-1.1.0.tgz#9397e6ce0a010cb57dcf8aef8754d3a5ce0ae36a" integrity sha512-XliasRSUfOz1/bAvTBaUlCjWDbceCW4y1DnvFfW7Yw9p2FbNRR0w8WoPdTxTCjKuoZ7/OQMeBxIe2y9Qy6rbYw== +"@joepie91/unreachable@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@joepie91/unreachable/-/unreachable-1.0.0.tgz#8032bb8a5813e81bbbe516cb3031d60818526687" + integrity sha512-vZRJ5UDq4mqP1vgSrcOLD3aIfS/nzwsvGFOOHv5sj5fa1Ss0dT1xnIzrXKLD9pu5EcUvF3K6n6jdaMW8uXpNEQ== + "@types/prop-types@*": version "15.7.4" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.4.tgz#fcf7205c25dff795ee79af1e30da2c9790808f11" @@ -471,6 +476,11 @@ arrify@^2.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== +as-expression@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/as-expression/-/as-expression-1.0.0.tgz#7bc620ca4cb2fe0ee90d86729bd6add33b8fd831" + integrity sha512-Iqh4GxNUfxbJdGn6b7/XMzc8m1Dz2ZHouBQ9DDTzyMRO3VPPIAXeoY/sucRxxxXKbUtzwzWZSN6jPR3zfpYHHA== + asn1@~0.2.3: version "0.2.4" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" @@ -1628,6 +1638,11 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +last-item@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/last-item/-/last-item-1.0.0.tgz#54e5fbb09e46f5ebcb3b334e1ca0e015fbda633b" + integrity sha512-1F6OpdMLea5/489SjQj2UVP4cXb9qpMxuUU4xHpUH86PnhGldKf7oDtu8hy7a+3GCdvikEFtP4y5PoPsYVnC6g== + lcov-parse@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-1.0.0.tgz#eb0d46b54111ebc561acb4c408ef9363bdc8f7e0" @@ -1723,6 +1738,11 @@ map-obj@^4.2.1: resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.2.1.tgz#e4ea399dbc979ae735c83c863dd31bdf364277b7" integrity sha512-+WA2/1sPmDj1dlvvJmB5G6JKfY9dpn7EVBUL06+y6PoljPkh+6V1QihwxNkbcGxCRjt2b0F9K0taiCuo7MbdFQ== +match-value@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/match-value/-/match-value-1.1.0.tgz#ad311ef8bbe2d344a53ec3104e28fe221984b98e" + integrity sha512-NOvpobcmkX+l9Eb6r2s3BkR1g1ZwzExDFdXA9d6p1r1O1olLbo88KuzMiBmg43xSpodfm7I6Hqlx2OoySquEgg== + mime-db@1.48.0: version "1.48.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d"