|
|
|
@ -6,10 +6,17 @@ const asExpression = require("as-expression");
|
|
|
|
|
const matchValue = require("match-value");
|
|
|
|
|
const unreachable = require("@joepie91/unreachable");
|
|
|
|
|
|
|
|
|
|
const { validateArguments } = require("@validatem/core");
|
|
|
|
|
const required = require("@validatem/required");
|
|
|
|
|
const anyProperty = require("@validatem/any-property");
|
|
|
|
|
const isString = require("@validatem/is-string");
|
|
|
|
|
const isFunction = require("@validatem/is-function");
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
const ensureArray = require("./util/ensure-array");
|
|
|
|
|
|
|
|
|
|
var tls = require('tls'),
|
|
|
|
|
Socket = require('net').Socket,
|
|
|
|
@ -1238,56 +1245,155 @@ Object.defineProperty(Connection.prototype, 'seq', { get: function() {
|
|
|
|
|
};
|
|
|
|
|
}});
|
|
|
|
|
|
|
|
|
|
function createCommandHandlers(rules) {
|
|
|
|
|
function parseTypes(typeString) {
|
|
|
|
|
if (typeString === NoRequest || typeString === AnyRequest) {
|
|
|
|
|
return [ typeString ];
|
|
|
|
|
} else {
|
|
|
|
|
return typeString
|
|
|
|
|
.split(/\s*,\s*/)
|
|
|
|
|
.map((type) => type.toUpperCase());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createCommandHandlers(_rules) {
|
|
|
|
|
let [ rules ] = validateArguments(arguments, {
|
|
|
|
|
rules: anyProperty({
|
|
|
|
|
key: [ required ],
|
|
|
|
|
value: [ required, {
|
|
|
|
|
untagged: anyProperty({
|
|
|
|
|
key: [ isString ],
|
|
|
|
|
value: [ required, isFunction ]
|
|
|
|
|
}),
|
|
|
|
|
tagged: [ isFunction ]
|
|
|
|
|
}]
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
function setUntaggedHandler(requestType, responseType, handler) {
|
|
|
|
|
if (!untaggedHandlers.has(requestType)) {
|
|
|
|
|
untaggedHandlers.set(requestType, new Map());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let handlers = untaggedHandlers.get(requestType);
|
|
|
|
|
|
|
|
|
|
if (!handlers.has(responseType)) {
|
|
|
|
|
handlers.set(responseType, handler);
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(`Duplicate handler definition for untagged type '${requestType}' -> '${responseType}'`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setTaggedHandler(requestType, handler) {
|
|
|
|
|
if (!taggedHandlers.has(requestType)) {
|
|
|
|
|
taggedHandlers.set(requestType, handler);
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(`Duplicate handler definition for tagged type`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRequestType(request) {
|
|
|
|
|
return (request != null)
|
|
|
|
|
? request.type.toUpperCase()
|
|
|
|
|
: NoRequest;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTaggedHandler(request) {
|
|
|
|
|
return taggedHandlers.get(getRequestType(request));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getUntaggedHandler(request, responseType) {
|
|
|
|
|
let typeHandlers = untaggedHandlers.get(getRequestType(request));
|
|
|
|
|
let anyRequestHandlers = untaggedHandlers.get(AnyRequest);
|
|
|
|
|
|
|
|
|
|
// console.log({
|
|
|
|
|
// requestType: getRequestType(request),
|
|
|
|
|
// responseType: responseType,
|
|
|
|
|
// anyHandler: anyRequestHandlers.get(responseType)
|
|
|
|
|
// });
|
|
|
|
|
|
|
|
|
|
if (typeHandlers != null && typeHandlers.has(responseType)) {
|
|
|
|
|
return typeHandlers.get(responseType);
|
|
|
|
|
} else if (anyRequestHandlers != null && anyRequestHandlers.has(responseType)) {
|
|
|
|
|
return anyRequestHandlers.get(responseType);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let type of parsedTypes) {
|
|
|
|
|
for (let [ typeString, options ] of allEntries(rules)) {
|
|
|
|
|
for (let type of parseTypes(typeString)) {
|
|
|
|
|
if (options.untagged != null) {
|
|
|
|
|
untaggedHandlers.set(type, options.untagged);
|
|
|
|
|
for (let [ responseTypeString, handler ] of allEntries(options.untagged)) {
|
|
|
|
|
for (let responseType of parseTypes(responseTypeString)) {
|
|
|
|
|
setUntaggedHandler(type, responseType, handler);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (options.tagged != null) {
|
|
|
|
|
taggedHandlers.set(type, options.tagged);
|
|
|
|
|
setTaggedHandler(type, options.tagged);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// REFACTOR: Eventually remove `.call(this` hackery
|
|
|
|
|
// REFACTOR: Remove all the toUpperCase stuff and normalize this in the core instead, so that we can just *assume* it to be upper-case here
|
|
|
|
|
return {
|
|
|
|
|
canHandleUntagged: function (request) {
|
|
|
|
|
return untaggedHandlers.has(request.type.toUpperCase());
|
|
|
|
|
canHandleUntagged: function (request, data) {
|
|
|
|
|
return (getUntaggedHandler(request, data.type.toUpperCase()) != null);
|
|
|
|
|
},
|
|
|
|
|
canHandleTagged: function (request) {
|
|
|
|
|
return taggedHandlers.has(request.type.toUpperCase());
|
|
|
|
|
canHandleTagged: function (request, _data) {
|
|
|
|
|
return (getTaggedHandler(request) != null);
|
|
|
|
|
},
|
|
|
|
|
handleUntagged: function (request, data) {
|
|
|
|
|
let handler = untaggedHandlers.get(request.type.toUpperCase());
|
|
|
|
|
let handler = getUntaggedHandler(request, data.type.toUpperCase());
|
|
|
|
|
|
|
|
|
|
return handler.call(this, request, data);
|
|
|
|
|
},
|
|
|
|
|
handleTagged: function (request, data) {
|
|
|
|
|
let handler = taggedHandlers.get(request.type.toUpperCase());
|
|
|
|
|
let handler = getTaggedHandler(request);
|
|
|
|
|
|
|
|
|
|
return handler.call(this, request, data);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function allEntries(object) {
|
|
|
|
|
// The equivalent of Object.entries but also including Symbol keys
|
|
|
|
|
return Reflect.ownKeys(object).map((key) => {
|
|
|
|
|
return [ key, object[key] ];
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let NoRequest = Symbol("NoRequest");
|
|
|
|
|
let AnyRequest = Symbol("AnyRequest");
|
|
|
|
|
|
|
|
|
|
// FIXME: Strip "UID" prefix from command names before matching, as these only affect the output format, but not the type of response
|
|
|
|
|
// Exception: EXPUNGE is allowed during UID commands, but not during their non-UID equivalents: https://datatracker.ietf.org/doc/html/rfc3501#page-73
|
|
|
|
|
let commandHandlers = createCommandHandlers({
|
|
|
|
|
[AnyRequest]: {
|
|
|
|
|
untagged: {
|
|
|
|
|
"BYE": function (_request, _) {
|
|
|
|
|
this._sock.end();
|
|
|
|
|
},
|
|
|
|
|
"CAPABILITY": function (_request, { payload }) {
|
|
|
|
|
this._caps = payload.map((v) => v.toUpperCase());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"LIST, XLIST, LSUB": {
|
|
|
|
|
untagged: function (request, { payload }) {
|
|
|
|
|
if (request.delimiter === undefined) {
|
|
|
|
|
request.delimiter = payload.delimiter;
|
|
|
|
|
} else {
|
|
|
|
|
if (request.boxBuilder == null) {
|
|
|
|
|
request.boxBuilder = createBoxTreeBuilder();
|
|
|
|
|
untagged: {
|
|
|
|
|
"LIST, XLIST, LSUB": function (request, { payload }) {
|
|
|
|
|
if (request.delimiter === undefined) {
|
|
|
|
|
request.delimiter = payload.delimiter;
|
|
|
|
|
} else {
|
|
|
|
|
if (request.boxBuilder == null) {
|
|
|
|
|
request.boxBuilder = createBoxTreeBuilder();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request.boxBuilder.add(payload);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request._curReq.boxBuilder.add(payload);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
tagged: function (request, _) {
|
|
|
|
@ -1301,16 +1407,114 @@ let commandHandlers = createCommandHandlers({
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"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);
|
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc2971
|
|
|
|
|
// Used for communicating server/client name, version, etc.
|
|
|
|
|
untagged: {
|
|
|
|
|
"ID": function (request, { payload }) {
|
|
|
|
|
request.responseData.serverVersion = payload;
|
|
|
|
|
request.legacyArgs.push(payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"ESEARCH": {
|
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc4731 / https://datatracker.ietf.org/doc/html/rfc7377
|
|
|
|
|
untagged: {
|
|
|
|
|
"ESEARCH": function (request, { payload }) {
|
|
|
|
|
Object.assign(request.responseData, payload); // Protocol-defined attributes. TODO: Improve the key names for this? Or is there extensibility?
|
|
|
|
|
request.legacyArgs.push(payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"SORT": {
|
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc5256
|
|
|
|
|
untagged: {
|
|
|
|
|
"SORT": function (request, { payload }) {
|
|
|
|
|
request.responseData.UIDs = payload;
|
|
|
|
|
request.legacyArgs.push(payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"THREAD": {
|
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc5256
|
|
|
|
|
untagged: {
|
|
|
|
|
"THREAD": function (request, { payload }) {
|
|
|
|
|
request.responseData.threads = payload; // FIXME: Work out the exact format
|
|
|
|
|
request.legacyArgs.push(payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"SEARCH": {
|
|
|
|
|
untagged: {
|
|
|
|
|
"SEARCH": function (request, { payload }) {
|
|
|
|
|
if (payload.results !== undefined) {
|
|
|
|
|
// CONDSTORE-modified search results
|
|
|
|
|
request.legacyArgs.push(payload.results);
|
|
|
|
|
request.legacyArgs.push(payload.modseq);
|
|
|
|
|
} else {
|
|
|
|
|
request.legacyArgs.push(payload);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"GETQUOTA, GETQUOTAROOT": {
|
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc2087
|
|
|
|
|
untagged: {
|
|
|
|
|
"QUOTA": function (request, { payload }) {
|
|
|
|
|
ensureArray(request.responseData, "quota");
|
|
|
|
|
request.responseData.quota.push(payload);
|
|
|
|
|
|
|
|
|
|
ensureArray(request.legacyArgs, 0);
|
|
|
|
|
request.legacyArgs[0].push(payload);
|
|
|
|
|
},
|
|
|
|
|
"QUOTAROOT": function (_request, _) {
|
|
|
|
|
throw new Error(`Not implemented`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"SELECT, EXAMINE": {
|
|
|
|
|
untagged: {
|
|
|
|
|
"RECENT": function (_request, { sequenceNumber }) {
|
|
|
|
|
this._ensureBox();
|
|
|
|
|
|
|
|
|
|
// FIXME: This conditional is always true?
|
|
|
|
|
if (this._box) {
|
|
|
|
|
this._box.messages.new = sequenceNumber;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"FLAGS": function (_request, { payload }) {
|
|
|
|
|
this._ensureBox();
|
|
|
|
|
|
|
|
|
|
// FIXME: This conditional is always true?
|
|
|
|
|
if (this._box) {
|
|
|
|
|
this._box.flags = payload;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"EXISTS": function (_request, { sequenceNumber }) {
|
|
|
|
|
this._ensureBox();
|
|
|
|
|
|
|
|
|
|
// FIXME: This conditional is always true?
|
|
|
|
|
if (this._box) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Connection.prototype._ensureBox = function () {
|
|
|
|
|
if (!this._box) {
|
|
|
|
|
if (RE_OPENBOX.test(this._curReq.type)) {
|
|
|
|
|
this._resetCurrentBox();
|
|
|
|
|
} else {
|
|
|
|
|
throw new Error(`Received a box-related response while not processing a box-related request`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@ -1324,15 +1528,13 @@ Connection.prototype._resUntagged = function({ type, num: sequenceNumber, textCo
|
|
|
|
|
// console.log("resUntagged", { type, num: sequenceNumber, payload, textCode: responseData });
|
|
|
|
|
var i, len, box, destinationKey;
|
|
|
|
|
|
|
|
|
|
if (this._curReq != null && commandHandlers.canHandleUntagged(this._curReq)) {
|
|
|
|
|
let response = { type, sequenceNumber, payload };
|
|
|
|
|
|
|
|
|
|
if (commandHandlers.canHandleUntagged(this._curReq, response)) {
|
|
|
|
|
// FIXME: Include other fields
|
|
|
|
|
commandHandlers.handleUntagged.call(this, this._curReq, { sequenceNumber, payload });
|
|
|
|
|
} else if (type === 'bye') {
|
|
|
|
|
this._sock.end();
|
|
|
|
|
commandHandlers.handleUntagged.call(this, this._curReq, response);
|
|
|
|
|
} else if (type === 'namespace') {
|
|
|
|
|
this.namespaces = payload;
|
|
|
|
|
} else if (type === 'capability') {
|
|
|
|
|
this._caps = payload.map((v) => v.toUpperCase());
|
|
|
|
|
} else if (type === 'preauth') {
|
|
|
|
|
this.state = 'authenticated';
|
|
|
|
|
} else if (type === 'expunge') {
|
|
|
|
@ -1387,50 +1589,6 @@ Connection.prototype._resUntagged = function({ type, num: sequenceNumber, textCo
|
|
|
|
|
} 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) {
|
|
|
|
|
// CONDSTORE-modified search results
|
|
|
|
|
this._curReq.cbargs.push(payload.results);
|
|
|
|
|
this._curReq.cbargs.push(payload.modseq);
|
|
|
|
|
} else {
|
|
|
|
|
this._curReq.cbargs.push(payload);
|
|
|
|
|
}
|
|
|
|
|
} else if (type === 'quota') {
|
|
|
|
|
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._resetCurrentBox();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this._box) {
|
|
|
|
|
this._box.messages.new = sequenceNumber;
|
|
|
|
|
}
|
|
|
|
|
} else if (type === 'flags') {
|
|
|
|
|
if (!this._box && RE_OPENBOX.test(this._curReq.type)) {
|
|
|
|
|
this._resetCurrentBox();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this._box) {
|
|
|
|
|
this._box.flags = payload;
|
|
|
|
|
}
|
|
|
|
|
} else if (type === 'bad' || type === 'no') {
|
|
|
|
|
if (this.state === 'connected' && !this._curReq) {
|
|
|
|
|
clearTimeout(this._connectionTimeout);
|
|
|
|
@ -1440,19 +1598,6 @@ Connection.prototype._resUntagged = function({ type, num: sequenceNumber, textCo
|
|
|
|
|
this.emit('error', err);
|
|
|
|
|
this._sock.end();
|
|
|
|
|
}
|
|
|
|
|
} else if (type === 'exists') {
|
|
|
|
|
if (!this._box && RE_OPENBOX.test(this._curReq.type)) {
|
|
|
|
|
this._resetCurrentBox();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this._box) {
|
|
|
|
|
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 === 'status') {
|
|
|
|
|
let attrs = defaultValue(payload.attrs, {});
|
|
|
|
|
|
|
|
|
@ -1565,9 +1710,11 @@ Connection.prototype._resTagged = function({ type, tagnum, text: payload, textCo
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (commandHandlers.canHandleTagged(request)) {
|
|
|
|
|
let response = { type, payload };
|
|
|
|
|
|
|
|
|
|
if (commandHandlers.canHandleTagged(request, response)) {
|
|
|
|
|
// FIXME: Add other fields with a sensible name
|
|
|
|
|
commandHandlers.handleTagged.call(this, request, { payload });
|
|
|
|
|
commandHandlers.handleTagged.call(this, request, response);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// console.dir({ done: request.cbargs }, { depth: null, colors: true });
|
|
|
|
|