emailjs-imap-client
Version:
JavaScript IMAP client
844 lines (730 loc) • 28.4 kB
JavaScript
import { propOr } from 'ramda'
import TCPSocket from 'emailjs-tcp-socket'
import { toTypedArray, fromTypedArray } from './common'
import { parser, compiler } from 'emailjs-imap-handler'
import Compression from './compression'
import CompressionBlob from '../res/compression.worker.blob'
//
// constants used for communication with the worker
//
const MESSAGE_INITIALIZE_WORKER = 'start'
const MESSAGE_INFLATE = 'inflate'
const MESSAGE_INFLATED_DATA_READY = 'inflated_ready'
const MESSAGE_DEFLATE = 'deflate'
const MESSAGE_DEFLATED_DATA_READY = 'deflated_ready'
const EOL = '\r\n'
const LINE_FEED = 10
const CARRIAGE_RETURN = 13
const LEFT_CURLY_BRACKET = 123
const RIGHT_CURLY_BRACKET = 125
const ASCII_PLUS = 43
// State tracking when constructing an IMAP command from buffers.
const BUFFER_STATE_LITERAL = 'literal'
const BUFFER_STATE_POSSIBLY_LITERAL_LENGTH_1 = 'literal_length_1'
const BUFFER_STATE_POSSIBLY_LITERAL_LENGTH_2 = 'literal_length_2'
const BUFFER_STATE_DEFAULT = 'default'
/**
* How much time to wait since the last response until the connection is considered idling
*/
const TIMEOUT_ENTER_IDLE = 1000
/**
* Lower Bound for socket timeout to wait since the last data was written to a socket
*/
const TIMEOUT_SOCKET_LOWER_BOUND = 10000
/**
* Multiplier for socket timeout:
*
* We assume at least a GPRS connection with 115 kb/s = 14,375 kB/s tops, so 10 KB/s to be on
* the safe side. We can timeout after a lower bound of 10s + (n KB / 10 KB/s). A 1 MB message
* upload would be 110 seconds to wait for the timeout. 10 KB/s === 0.1 s/B
*/
const TIMEOUT_SOCKET_MULTIPLIER = 0.1
/**
* Creates a connection object to an IMAP server. Call `connect` method to inititate
* the actual connection, the constructor only defines the properties but does not actually connect.
*
* @constructor
*
* @param {String} [host='localhost'] Hostname to conenct to
* @param {Number} [port=143] Port number to connect to
* @param {Object} [options] Optional options object
* @param {Boolean} [options.useSecureTransport] Set to true, to use encrypted connection
* @param {String} [options.compressionWorkerPath] offloads de-/compression computation to a web worker, this is the path to the browserified emailjs-compressor-worker.js
*/
export default class Imap {
constructor (host, port, options = {}) {
this.timeoutEnterIdle = TIMEOUT_ENTER_IDLE
this.timeoutSocketLowerBound = TIMEOUT_SOCKET_LOWER_BOUND
this.timeoutSocketMultiplier = TIMEOUT_SOCKET_MULTIPLIER
this.options = options
this.port = port || (this.options.useSecureTransport ? 993 : 143)
this.host = host || 'localhost'
// Use a TLS connection. Port 993 also forces TLS.
this.options.useSecureTransport = 'useSecureTransport' in this.options ? !!this.options.useSecureTransport : this.port === 993
this.secureMode = !!this.options.useSecureTransport // Does the connection use SSL/TLS
this._connectionReady = false // Is the conection established and greeting is received from the server
this._globalAcceptUntagged = {} // Global handlers for unrelated responses (EXPUNGE, EXISTS etc.)
this._clientQueue = [] // Queue of outgoing commands
this._canSend = false // Is it OK to send something to the server
this._tagCounter = 0 // Counter to allow uniqueue imap tags
this._currentCommand = false // Current command that is waiting for response from the server
this._idleTimer = false // Timer waiting to enter idle
this._socketTimeoutTimer = false // Timer waiting to declare the socket dead starting from the last write
this.compressed = false // Is the connection compressed and needs inflating/deflating
//
// HELPERS
//
// As the server sends data in chunks, it needs to be split into separate lines. Helps parsing the input.
this._incomingBuffers = []
this._bufferState = BUFFER_STATE_DEFAULT
this._literalRemaining = 0
//
// Event placeholders, may be overriden with callback functions
//
this.oncert = null
this.onerror = null // Irrecoverable error occurred. Connection to the server will be closed automatically.
this.onready = null // The connection to the server has been established and greeting is received
this.onidle = null // There are no more commands to process
}
// PUBLIC METHODS
/**
* Initiate a connection to the server. Wait for onready event
*
* @param {Object} Socket
* TESTING ONLY! The TCPSocket has a pretty nonsensical convenience constructor,
* which makes it hard to mock. For dependency-injection purposes, we use the
* Socket parameter to pass in a mock Socket implementation. Should be left blank
* in production use!
* @returns {Promise} Resolves when socket is opened
*/
connect (Socket = TCPSocket) {
return new Promise((resolve, reject) => {
this.socket = Socket.open(this.host, this.port, {
binaryType: 'arraybuffer',
useSecureTransport: this.secureMode,
ca: this.options.ca
})
// allows certificate handling for platform w/o native tls support
// oncert is non standard so setting it might throw if the socket object is immutable
try {
this.socket.oncert = (cert) => { this.oncert && this.oncert(cert) }
} catch (E) { }
// Connection closing unexpected is an error
this.socket.onclose = () => this._onError(new Error('Socket closed unexpectedly!'))
this.socket.ondata = (evt) => {
try {
this._onData(evt)
} catch (err) {
this._onError(err)
}
}
// if an error happens during create time, reject the promise
this.socket.onerror = (e) => {
reject(new Error('Could not open socket: ' + e.data.message))
}
this.socket.onopen = () => {
// use proper "irrecoverable error, tear down everything"-handler only after socket is open
this.socket.onerror = (e) => this._onError(e)
resolve()
}
})
}
/**
* Closes the connection to the server
*
* @returns {Promise} Resolves when the socket is closed
*/
close (error) {
return new Promise((resolve) => {
var tearDown = () => {
// fulfill pending promises
this._clientQueue.forEach(cmd => cmd.callback(error))
if (this._currentCommand) {
this._currentCommand.callback(error)
}
this._clientQueue = []
this._currentCommand = false
clearTimeout(this._idleTimer)
this._idleTimer = null
clearTimeout(this._socketTimeoutTimer)
this._socketTimeoutTimer = null
if (this.socket) {
// remove all listeners
this.socket.onopen = null
this.socket.onclose = null
this.socket.ondata = null
this.socket.onerror = null
try {
this.socket.oncert = null
} catch (E) { }
this.socket = null
}
resolve()
}
this._disableCompression()
if (!this.socket || this.socket.readyState !== 'open') {
return tearDown()
}
this.socket.onclose = this.socket.onerror = tearDown // we don't really care about the error here
this.socket.close()
})
}
/**
* Send LOGOUT to the server.
*
* Use is discouraged!
*
* @returns {Promise} Resolves when connection is closed by server.
*/
logout () {
return new Promise((resolve, reject) => {
this.socket.onclose = this.socket.onerror = () => {
this.close('Client logging out').then(resolve).catch(reject)
}
this.enqueueCommand('LOGOUT')
})
}
/**
* Initiates TLS handshake
*/
upgrade () {
this.secureMode = true
this.socket.upgradeToSecure()
}
/**
* Schedules a command to be sent to the server.
* See https://github.com/emailjs/emailjs-imap-handler for request structure.
* Do not provide a tag property, it will be set by the queue manager.
*
* To catch untagged responses use acceptUntagged property. For example, if
* the value for it is 'FETCH' then the reponse includes 'payload.FETCH' property
* that is an array including all listed * FETCH responses.
*
* @param {Object} request Structured request object
* @param {Array} acceptUntagged a list of untagged responses that will be included in 'payload' property
* @param {Object} [options] Optional data for the command payload
* @returns {Promise} Promise that resolves when the corresponding response was received
*/
enqueueCommand (request, acceptUntagged, options) {
if (typeof request === 'string') {
request = {
command: request
}
}
acceptUntagged = [].concat(acceptUntagged || []).map((untagged) => (untagged || '').toString().toUpperCase().trim())
var tag = 'W' + (++this._tagCounter)
request.tag = tag
return new Promise((resolve, reject) => {
var data = {
tag: tag,
request: request,
payload: acceptUntagged.length ? {} : undefined,
callback: (response) => {
if (this.isError(response)) {
return reject(response)
} else if (['NO', 'BAD'].indexOf(propOr('', 'command', response).toUpperCase().trim()) >= 0) {
var error = new Error(response.humanReadable || 'Error')
if (response.code) {
error.code = response.code
}
return reject(error)
}
resolve(response)
}
}
// apply any additional options to the command
Object.keys(options || {}).forEach((key) => { data[key] = options[key] })
acceptUntagged.forEach((command) => { data.payload[command] = [] })
// if we're in priority mode (i.e. we ran commands in a precheck),
// queue any commands BEFORE the command that contianed the precheck,
// otherwise just queue command as usual
var index = data.ctx ? this._clientQueue.indexOf(data.ctx) : -1
if (index >= 0) {
data.tag += '.p'
data.request.tag += '.p'
this._clientQueue.splice(index, 0, data)
} else {
this._clientQueue.push(data)
}
if (this._canSend) {
this._sendRequest()
}
})
}
/**
*
* @param commands
* @param ctx
* @returns {*}
*/
getPreviouslyQueued (commands, ctx) {
const startIndex = this._clientQueue.indexOf(ctx) - 1
// search backwards for the commands and return the first found
for (let i = startIndex; i >= 0; i--) {
if (isMatch(this._clientQueue[i])) {
return this._clientQueue[i]
}
}
// also check current command if no SELECT is queued
if (isMatch(this._currentCommand)) {
return this._currentCommand
}
return false
function isMatch (data) {
return data && data.request && commands.indexOf(data.request.command) >= 0
}
}
/**
* Send data to the TCP socket
* Arms a timeout waiting for a response from the server.
*
* @param {String} str Payload
*/
send (str) {
const buffer = toTypedArray(str).buffer
const timeout = this.timeoutSocketLowerBound + Math.floor(buffer.byteLength * this.timeoutSocketMultiplier)
clearTimeout(this._socketTimeoutTimer) // clear pending timeouts
this._socketTimeoutTimer = setTimeout(() => this._onError(new Error(' Socket timed out!')), timeout) // arm the next timeout
if (this.compressed) {
this._sendCompressed(buffer)
} else {
this.socket.send(buffer)
}
}
/**
* Set a global handler for an untagged response. If currently processed command
* has not listed untagged command it is forwarded to the global handler. Useful
* with EXPUNGE, EXISTS etc.
*
* @param {String} command Untagged command name
* @param {Function} callback Callback function with response object and continue callback function
*/
setHandler (command, callback) {
this._globalAcceptUntagged[command.toUpperCase().trim()] = callback
}
// INTERNAL EVENTS
/**
* Error handler for the socket
*
* @event
* @param {Event} evt Event object. See evt.data for the error
*/
_onError (evt) {
var error
if (this.isError(evt)) {
error = evt
} else if (evt && this.isError(evt.data)) {
error = evt.data
} else {
error = new Error((evt && evt.data && evt.data.message) || evt.data || evt || 'Error')
}
this.logger.error(error)
// always call onerror callback, no matter if close() succeeds or fails
this.close(error).then(() => {
this.onerror && this.onerror(error)
}, () => {
this.onerror && this.onerror(error)
})
}
/**
* Handler for incoming data from the server. The data is sent in arbitrary
* chunks and can't be used directly so this function makes sure the data
* is split into complete lines before the data is passed to the command
* handler
*
* @param {Event} evt
*/
_onData (evt) {
clearTimeout(this._socketTimeoutTimer) // reset the timeout on each data packet
const timeout = this.timeoutSocketLowerBound + Math.floor(4096 * this.timeoutSocketMultiplier) // max packet size is 4096 bytes
this._socketTimeoutTimer = setTimeout(() => this._onError(new Error(' Socket timed out!')), timeout)
this._incomingBuffers.push(new Uint8Array(evt.data)) // append to the incoming buffer
this._parseIncomingCommands(this._iterateIncomingBuffer()) // Consume the incoming buffer
}
* _iterateIncomingBuffer () {
let buf = this._incomingBuffers[this._incomingBuffers.length - 1] || []
let i = 0
// loop invariant:
// this._incomingBuffers starts with the beginning of incoming command.
// buf is shorthand for last element of this._incomingBuffers.
// buf[0..i-1] is part of incoming command.
while (i < buf.length) {
switch (this._bufferState) {
case BUFFER_STATE_LITERAL:
const diff = Math.min(buf.length - i, this._literalRemaining)
this._literalRemaining -= diff
i += diff
if (this._literalRemaining === 0) {
this._bufferState = BUFFER_STATE_DEFAULT
}
continue
case BUFFER_STATE_POSSIBLY_LITERAL_LENGTH_2:
if (i < buf.length) {
if (buf[i] === CARRIAGE_RETURN) {
this._literalRemaining = Number(fromTypedArray(this._lengthBuffer)) + 2 // for CRLF
this._bufferState = BUFFER_STATE_LITERAL
} else {
this._bufferState = BUFFER_STATE_DEFAULT
}
delete this._lengthBuffer
}
continue
case BUFFER_STATE_POSSIBLY_LITERAL_LENGTH_1:
const start = i
while (i < buf.length && buf[i] >= 48 && buf[i] <= 57) { // digits
i++
}
if (start !== i) {
const latest = buf.subarray(start, i)
const prevBuf = this._lengthBuffer
this._lengthBuffer = new Uint8Array(prevBuf.length + latest.length)
this._lengthBuffer.set(prevBuf)
this._lengthBuffer.set(latest, prevBuf.length)
}
if (i < buf.length) {
if (this._lengthBuffer.length > 0 && buf[i] === RIGHT_CURLY_BRACKET) {
this._bufferState = BUFFER_STATE_POSSIBLY_LITERAL_LENGTH_2
} else {
delete this._lengthBuffer
this._bufferState = BUFFER_STATE_DEFAULT
}
i++
}
continue
default:
// find literal length
const leftIdx = buf.indexOf(LEFT_CURLY_BRACKET, i)
if (leftIdx > -1) {
const leftOfLeftCurly = new Uint8Array(buf.buffer, i, leftIdx - i)
if (leftOfLeftCurly.indexOf(LINE_FEED) === -1) {
i = leftIdx + 1
this._lengthBuffer = new Uint8Array(0)
this._bufferState = BUFFER_STATE_POSSIBLY_LITERAL_LENGTH_1
continue
}
}
// find end of command
const LFidx = buf.indexOf(LINE_FEED, i)
if (LFidx > -1) {
if (LFidx < buf.length - 1) {
this._incomingBuffers[this._incomingBuffers.length - 1] = new Uint8Array(buf.buffer, 0, LFidx + 1)
}
const commandLength = this._incomingBuffers.reduce((prev, curr) => prev + curr.length, 0) - 2 // 2 for CRLF
const command = new Uint8Array(commandLength)
let index = 0
while (this._incomingBuffers.length > 0) {
let uint8Array = this._incomingBuffers.shift()
const remainingLength = commandLength - index
if (uint8Array.length > remainingLength) {
const excessLength = uint8Array.length - remainingLength
uint8Array = uint8Array.subarray(0, -excessLength)
if (this._incomingBuffers.length > 0) {
this._incomingBuffers = []
}
}
command.set(uint8Array, index)
index += uint8Array.length
}
yield command
if (LFidx < buf.length - 1) {
buf = new Uint8Array(buf.subarray(LFidx + 1))
this._incomingBuffers.push(buf)
i = 0
} else {
// clear the timeout when an entire command has arrived
// and not waiting on more data for next command
clearTimeout(this._socketTimeoutTimer)
this._socketTimeoutTimer = null
return
}
} else {
return
}
}
}
}
// PRIVATE METHODS
/**
* Processes a command from the queue. The command is parsed and feeded to a handler
*/
_parseIncomingCommands (commands) {
for (var command of commands) {
this._clearIdle()
/*
* The "+"-tagged response is a special case:
* Either the server can asks for the next chunk of data, e.g. for the AUTHENTICATE command.
*
* Or there was an error in the XOAUTH2 authentication, for which SASL initial client response extension
* dictates the client sends an empty EOL response to the challenge containing the error message.
*
* Details on "+"-tagged response:
* https://tools.ietf.org/html/rfc3501#section-2.2.1
*/
//
if (command[0] === ASCII_PLUS) {
if (this._currentCommand.data.length) {
// feed the next chunk of data
var chunk = this._currentCommand.data.shift()
chunk += (!this._currentCommand.data.length ? EOL : '') // EOL if there's nothing more to send
this.send(chunk)
} else if (this._currentCommand.errorResponseExpectsEmptyLine) {
this.send(EOL) // XOAUTH2 empty response, error will be reported when server continues with NO response
}
continue
}
var response
try {
const valueAsString = this._currentCommand.request && this._currentCommand.request.valueAsString
response = parser(command, { valueAsString })
this.logger.debug('S:', () => compiler(response, false, true))
} catch (e) {
this.logger.error('Error parsing imap command!', response)
return this._onError(e)
}
this._processResponse(response)
this._handleResponse(response)
// first response from the server, connection is now usable
if (!this._connectionReady) {
this._connectionReady = true
this.onready && this.onready()
}
}
}
/**
* Feeds a parsed response object to an appropriate handler
*
* @param {Object} response Parsed command object
*/
_handleResponse (response) {
var command = propOr('', 'command', response).toUpperCase().trim()
if (!this._currentCommand) {
// unsolicited untagged response
if (response.tag === '*' && command in this._globalAcceptUntagged) {
this._globalAcceptUntagged[command](response)
this._canSend = true
this._sendRequest()
}
} else if (this._currentCommand.payload && response.tag === '*' && command in this._currentCommand.payload) {
// expected untagged response
this._currentCommand.payload[command].push(response)
} else if (response.tag === '*' && command in this._globalAcceptUntagged) {
// unexpected untagged response
this._globalAcceptUntagged[command](response)
} else if (response.tag === this._currentCommand.tag) {
// tagged response
if (this._currentCommand.payload && Object.keys(this._currentCommand.payload).length) {
response.payload = this._currentCommand.payload
}
this._currentCommand.callback(response)
this._canSend = true
this._sendRequest()
}
}
/**
* Sends a command from client queue to the server.
*/
_sendRequest () {
if (!this._clientQueue.length) {
return this._enterIdle()
}
this._clearIdle()
// an operation was made in the precheck, no need to restart the queue manually
this._restartQueue = false
var command = this._clientQueue[0]
if (typeof command.precheck === 'function') {
// remember the context
var context = command
var precheck = context.precheck
delete context.precheck
// we need to restart the queue handling if no operation was made in the precheck
this._restartQueue = true
// invoke the precheck command and resume normal operation after the promise resolves
precheck(context).then(() => {
// we're done with the precheck
if (this._restartQueue) {
// we need to restart the queue handling
this._sendRequest()
}
}).catch((err) => {
// precheck failed, so we remove the initial command
// from the queue, invoke its callback and resume normal operation
let cmd
const index = this._clientQueue.indexOf(context)
if (index >= 0) {
cmd = this._clientQueue.splice(index, 1)[0]
}
if (cmd && cmd.callback) {
cmd.callback(err)
this._canSend = true
this._parseIncomingCommands(this._iterateIncomingBuffer()) // Consume the rest of the incoming buffer
this._sendRequest() // continue sending
}
})
return
}
this._canSend = false
this._currentCommand = this._clientQueue.shift()
try {
this._currentCommand.data = compiler(this._currentCommand.request, true)
this.logger.debug('C:', () => compiler(this._currentCommand.request, false, true)) // excludes passwords etc.
} catch (e) {
this.logger.error('Error compiling imap command!', this._currentCommand.request)
return this._onError(new Error('Error compiling imap command!'))
}
var data = this._currentCommand.data.shift()
this.send(data + (!this._currentCommand.data.length ? EOL : ''))
return this.waitDrain
}
/**
* Emits onidle, noting to do currently
*/
_enterIdle () {
clearTimeout(this._idleTimer)
this._idleTimer = setTimeout(() => (this.onidle && this.onidle()), this.timeoutEnterIdle)
}
/**
* Cancel idle timer
*/
_clearIdle () {
clearTimeout(this._idleTimer)
this._idleTimer = null
}
/**
* Method processes a response into an easier to handle format.
* Add untagged numbered responses (e.g. FETCH) into a nicely feasible form
* Checks if a response includes optional response codes
* and copies these into separate properties. For example the
* following response includes a capability listing and a human
* readable message:
*
* * OK [CAPABILITY ID NAMESPACE] All ready
*
* This method adds a 'capability' property with an array value ['ID', 'NAMESPACE']
* to the response object. Additionally 'All ready' is added as 'humanReadable' property.
*
* See possiblem IMAP Response Codes at https://tools.ietf.org/html/rfc5530
*
* @param {Object} response Parsed response object
*/
_processResponse (response) {
const command = propOr('', 'command', response).toUpperCase().trim()
// no attributes
if (!response || !response.attributes || !response.attributes.length) {
return
}
// untagged responses w/ sequence numbers
if (response.tag === '*' && /^\d+$/.test(response.command) && response.attributes[0].type === 'ATOM') {
response.nr = Number(response.command)
response.command = (response.attributes.shift().value || '').toString().toUpperCase().trim()
}
// no optional response code
if (['OK', 'NO', 'BAD', 'BYE', 'PREAUTH'].indexOf(command) < 0) {
return
}
// If last element of the response is TEXT then this is for humans
if (response.attributes[response.attributes.length - 1].type === 'TEXT') {
response.humanReadable = response.attributes[response.attributes.length - 1].value
}
// Parse and format ATOM values
if (response.attributes[0].type === 'ATOM' && response.attributes[0].section) {
const option = response.attributes[0].section.map((key) => {
if (!key) {
return
}
if (Array.isArray(key)) {
return key.map((key) => (key.value || '').toString().trim())
} else {
return (key.value || '').toString().toUpperCase().trim()
}
})
const key = option.shift()
response.code = key
if (option.length === 1) {
response[key.toLowerCase()] = option[0]
} else if (option.length > 1) {
response[key.toLowerCase()] = option
}
}
}
/**
* Checks if a value is an Error object
*
* @param {Mixed} value Value to be checked
* @return {Boolean} returns true if the value is an Error
*/
isError (value) {
return !!Object.prototype.toString.call(value).match(/Error\]$/)
}
// COMPRESSION RELATED METHODS
/**
* Sets up deflate/inflate for the IO
*/
enableCompression () {
this._socketOnData = this.socket.ondata
this.compressed = true
if (typeof window !== 'undefined' && window.Worker) {
this._compressionWorker = new Worker(URL.createObjectURL(new Blob([CompressionBlob])))
this._compressionWorker.onmessage = (e) => {
var message = e.data.message
var data = e.data.buffer
switch (message) {
case MESSAGE_INFLATED_DATA_READY:
this._socketOnData({ data })
break
case MESSAGE_DEFLATED_DATA_READY:
this.waitDrain = this.socket.send(data)
break
}
}
this._compressionWorker.onerror = (e) => {
this._onError(new Error('Error handling compression web worker: ' + e.message))
}
this._compressionWorker.postMessage(createMessage(MESSAGE_INITIALIZE_WORKER))
} else {
const inflatedReady = (buffer) => { this._socketOnData({ data: buffer }) }
const deflatedReady = (buffer) => { this.waitDrain = this.socket.send(buffer) }
this._compression = new Compression(inflatedReady, deflatedReady)
}
// override data handler, decompress incoming data
this.socket.ondata = (evt) => {
if (!this.compressed) {
return
}
if (this._compressionWorker) {
this._compressionWorker.postMessage(createMessage(MESSAGE_INFLATE, evt.data), [evt.data])
} else {
this._compression.inflate(evt.data)
}
}
}
/**
* Undoes any changes related to compression. This only be called when closing the connection
*/
_disableCompression () {
if (!this.compressed) {
return
}
this.compressed = false
this.socket.ondata = this._socketOnData
this._socketOnData = null
if (this._compressionWorker) {
// terminate the worker
this._compressionWorker.terminate()
this._compressionWorker = null
}
}
/**
* Outgoing payload needs to be compressed and sent to socket
*
* @param {ArrayBuffer} buffer Outgoing uncompressed arraybuffer
*/
_sendCompressed (buffer) {
// deflate
if (this._compressionWorker) {
this._compressionWorker.postMessage(createMessage(MESSAGE_DEFLATE, buffer), [buffer])
} else {
this._compression.deflate(buffer)
}
}
}
const createMessage = (message, buffer) => ({ message, buffer })