Initial preview

This commit is contained in:
Andris Reinman 2017-03-05 23:45:50 +02:00
commit afd8abccc4
84 changed files with 22259 additions and 0 deletions

3
.eslintrc Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "nodemailer"
}

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules
.DS_Store
npm-debug.log
.npmrc

27
Gruntfile.js Normal file
View file

@ -0,0 +1,27 @@
'use strict';
module.exports = function (grunt) {
// Project configuration.
grunt.initConfig({
eslint: {
all: ['lib/**/*.js', 'imap-core/**/*.js', 'test/**/*.js', 'examples/**/*.js', 'Gruntfile.js']
},
mochaTest: {
all: {
options: {
reporter: 'spec'
},
src: ['test/**/*-test.js', 'imap-core/test/**/*-test.js']
}
}
});
// Load the plugin(s)
grunt.loadNpmTasks('grunt-eslint');
grunt.loadNpmTasks('grunt-mocha-test');
// Tasks
grunt.registerTask('default', ['eslint', 'mochaTest']);
};

298
LICENSE Normal file
View file

@ -0,0 +1,298 @@
Copyright (c) 2017 Andris Reinman
European Union Public Licence
V. 1.1
EUPL (c) the European Community 2007
This European Union Public Licence (the "EUPL") applies to the Work or Software
(as defined below) which is provided under the terms of this Licence. Any use
of the Work, other than as authorised under this Licence is prohibited (to the
extent such use is covered by a right of the copyright holder of the Work).
The Original Work is provided under the terms of this Licence when the Licensor
(as defined below) has placed the following notice immediately following the
copyright notice for the Original Work:
Licensed under the EUPL V.1.1
or has expressed by any other mean his willingness to license under the EUPL.
1. Definitions
In this Licence, the following terms have the following meaning:
* The Licence: this Licence.
* The Original Work or the Software: the software distributed and/or
communicated by the Licensor under this Licence, available as Source Code
and also as Executable Code as the case may be.
* Derivative Works: the works or software that could be created by the
Licensee, based upon the Original Work or modifications thereof. This
Licence does not define the extent of modification or dependence on the
Original Work required in order to classify a work as a Derivative Work;
this extent is determined by copyright law applicable in the country
mentioned in Article 15.
* The Work: the Original Work and/or its Derivative Works.
* The Source Code: the human-readable form of the Work which is the most
convenient for people to study and modify.
* The Executable Code: any code which has generally been compiled and which is
meant to be interpreted by a computer as a program.
* The Licensor: the natural or legal person that distributes and/or
communicates the Work under the Licence.
* Contributor(s): any natural or legal person who modifies the Work under the
Licence, or otherwise contributes to the creation of a Derivative Work.
* The Licensee or "You": any natural or legal person who makes any usage of
the Software under the terms of the Licence.
* Distribution and/or Communication: any act of selling, giving, lending,
renting, distributing, communicating, transmitting, or otherwise making
available, on-line or off-line, copies of the Work or providing access to
its essential functionalities at the disposal of any other natural or legal
person.
2. Scope of the rights granted by the Licence
The Licensor hereby grants You a world-wide, royalty-free, non-exclusive,
sublicensable licence to do the following, for the duration of copyright vested
in the Original Work:
* use the Work in any circumstance and for all usage,
* reproduce the Work,
* modify the Original Work, and make Derivative Works based upon the Work,
* communicate to the public, including the right to make available or display
the Work or copies thereof to the public and perform publicly, as the case
may be, the Work,
* distribute the Work or copies thereof,
* lend and rent the Work or copies thereof,
* sub-license rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now
known or later invented, as far as the applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to
exercise his moral right to the extent allowed by law in order to make
effective the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non exclusive usage rights to
any patents held by the Licensor, to the extent necessary to make use of the
rights granted on the Work under this Licence.
3. Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as
Executable Code. If the Work is provided as Executable Code, the Licensor
provides in addition a machine-readable copy of the Source Code of the Work
along with each copy of the Work that the Licensor distributes or indicates, in
a notice following the copyright notice attached to the Work, a repository
where the Source Code is easily and freely accessible for as long as the
Licensor continues to distribute and/or communicate the Work.
4. Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits
from any exception or limitation to the exclusive rights of the rights owners
in the Original Work or Software, of the exhaustion of those rights or of other
applicable limitations thereto.
5. Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and
obligations imposed on the Licensee. Those obligations are the following:
- Attribution right: the Licensee shall keep intact all copyright, patent or
trademarks notices and all notices that refer to the Licence and to the
disclaimer of warranties. The Licensee must include a copy of such notices
and a copy of the Licence with every copy of the Work he/she distributes
and/or communicates. The Licensee must cause any Derivative Work to carry
prominent notices stating that the Work has been modified and the date of
modification.
- Copyleft clause: If the Licensee distributes and/or communicates copies of
the Original Works or Derivative Works based upon the Original Work, this
Distribution and/or Communication will be done under the terms of this
Licence or of a later version of this Licence unless the Original Work is
expressly distributed only under this version of the Licence. The Licensee
(becoming Licensor) cannot offer or impose any additional terms or
conditions on the Work or Derivative Work that alter or restrict the terms
of the Licence.
- Compatibility clause: If the Licensee Distributes and/or Communicates
Derivative Works or copies thereof based upon both the Original Work and
another work licensed under a Compatible Licence, this Distribution and/or
Communication can be done under the terms of this Compatible Licence. For
the sake of this clause, "Compatible Licence" refers to the licences listed
in the appendix attached to this Licence. Should the Licensee's obligations
under the Compatible Licence conflict with his/her obligations under this
Licence, the obligations of the Compatible Licence shall prevail.
- Provision of Source Code: When distributing and/or communicating copies of
the Work, the Licensee will provide a machine-readable copy of the Source
Code or indicate a repository where this Source will be easily and freely
available for as long as the Licensee continues to distribute and/or
communicate the Work. Legal Protection: This Licence does not grant
permission to use the trade names, trademarks, service marks, or names of
the Licensor, except as required for reasonable and customary use in
describing the origin of the Work and reproducing the content of the
copyright notice.
6. Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted
hereunder is owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings
to the Work are owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent
Contributors grant You a licence to their contributions to the Work, under the
terms of this Licence.
7. Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous
contributors. It is not a finished work and may therefore contain defects or
"bugs" inherent to this type of software development.
For the above reason, the Work is provided under the Licence on an "as is"
basis and without warranties of any kind concerning the Work, including without
limitation merchantability, fitness for a particular purpose, absence of
defects or errors, accuracy, non-infringement of intellectual property rights
other than copyright as stated in Article 6 of this Licence.
This disclaimer of warranty is an essential part of the Licence and a condition
for the grant of any rights to the Work.
8. Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural
persons, the Licensor will in no event be liable for any direct or indirect,
material or moral, damages of any kind, arising out of the Licence or of the
use of the Work, including without limitation, damages for loss of goodwill,
work stoppage, computer failure or malfunction, loss of data or any commercial
damage, even if the Licensor has been advised of the possibility of such
damage. However, the Licensor will be liable under statutory product liability
laws as far such laws apply to the Work.
9. Additional agreements
While distributing the Original Work or Derivative Works, You may choose to
conclude an additional agreement to offer, and charge a fee for, acceptance of
support, warranty, indemnity, or other liability obligations and/or services
consistent with this Licence. However, in accepting such obligations, You may
act only on your own behalf and on your sole responsibility, not on behalf of
the original Licensor or any other Contributor, and only if You agree to
indemnify, defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against such Contributor by the fact You have
accepted any such warranty or additional liability.
10. Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon "I agree"
placed under the bottom of a window displaying the text of this Licence or by
affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable
acceptance of this Licence and all of its terms and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and
conditions by exercising any rights granted to You by Article 2 of this
Licence, such as the use of the Work, the creation by You of a Derivative Work
or the Distribution and/or Communication by You of the Work or copies thereof.
11. Information to the public
In case of any Distribution and/or Communication of the Work by means of
electronic communication by You (for example, by offering to download the Work
from a remote location) the distribution channel or media (for example, a
website) must at least provide to the public the information requested by the
applicable law regarding the Licensor, the Licence and the way it may be
accessible, concluded, stored and reproduced by the Licensee.
12. Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon
any breach by the Licensee of the terms of the Licence.
Such a termination will not terminate the licences of any person who has
received the Work from the Licensee under the Licence, provided such persons
remain in full compliance with the Licence.
13. Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete
agreement between the Parties as to the Work licensed hereunder.
If any provision of the Licence is invalid or unenforceable under applicable
law, this will not affect the validity or enforceability of the Licence as a
whole. Such provision will be construed and/or reformed so as necessary to make
it valid and enforceable.
The European Commission may publish other linguistic versions and/or new
versions of this Licence, so far this is required and reasonable, without
reducing the scope of the rights granted by the Licence. New versions of the
Licence will be published with a unique version number.
All linguistic versions of this Licence, approved by the European Commission,
have identical value. Parties can take advantage of the linguistic version of
their choice.
14. Jurisdiction
Any litigation resulting from the interpretation of this License, arising
between the European Commission, as a Licensor, and any Licensee, will be
subject to the jurisdiction of the Court of Justice of the European
Communities, as laid down in article 238 of the Treaty establishing the
European Community.
Any litigation arising between Parties, other than the European Commission, and
resulting from the interpretation of this License, will be subject to the
exclusive jurisdiction of the competent court where the Licensor resides or
conducts its primary business.
15. Applicable Law
This Licence shall be governed by the law of the European Union country where
the Licensor resides or has his registered office.
This licence shall be governed by the Belgian law if:
* a litigation arises between the European Commission, as a Licensor, and any
Licensee;
* the Licensor, other than the European Commission, has no residence or
registered office inside a European Union country.
Appendix
"Compatible Licences" according to article 5 EUPL are:
* GNU General Public License (GNU GPL) v. 2
* Open Software License (OSL) v. 2.1, v. 3.0
* Common Public License v. 1.0
* Eclipse Public License v. 1.0
* Cecill v. 2.0

24
README.md Normal file
View file

@ -0,0 +1,24 @@
# Wild Duck Mail Agent
This is a very early preview of an IMAP server built with Node.js and MongoDB.
### Goals of the Project
1. Build a scalable IMAP server that uses clustered database instead of single machine file system as mail store
2. Push notifications. Your application (eg. a webmail client) should be able to request changes (new and deleted messages, flag changes) to be pushed to client instead of using IMAP to fetch stuff from the server
## Usage
Install dependencies
npm install --production
Modify [config file](./config/default.js)
Run the server
npm start
## License
Wild Duck Mail Agent is licensed under the [European Union Public License 1.1](http://ec.europa.eu/idabc/eupl.html).

21
config/default.js Normal file
View file

@ -0,0 +1,21 @@
'use strict';
module.exports = {
log: {
level: 'silly'
},
imap: {
port: 9993,
host: '127.0.0.1',
maxUnflaggedMessages: 10
},
mongo: 'mongodb://127.0.0.1:27017/wildduck',
mx: {
port: 2525,
host: '0.0.0.0',
maxMB: 2
}
};

98
imap-core/README.md Normal file
View file

@ -0,0 +1,98 @@
# imap-core
Node.js module to create custom IMAP servers.
This is something I have used mostly for client work as the lower level dependency for some specific applications that serve IMAP. I don't have any such clients right no so I published the code if anyone finds it useful. I removed all proprietary code developed for clients, the module is only about the lower level protocol usage and does not contain any actual server logic.
You can see an example implementation of an IMAP server from the [example script](examples/index.js). Most of the code is inherited from the Hoodiecrow test-IMAP server module but this module can be used for asynchronous data access while in Hoodiecrow everything was synchronous (storage was an in-memory object that was accessed and updated synchronously).
## Demo
Install dependencies
npm install
Run the example
node examples/index.js
Connect to the server on port 9993
openssl s_client -crlf -connect localhost:9993
Once connected use testuser:pass to log in
< * OK test ready
> A LOGIN testuser pass
< A OK testuser authenticated
## IMAP extension support
This project is going to support only selected extensions, that are minimally required.
## Sequence number handling
Sequence numbers are handled automatically, no need to do this in the application side you only need to keep count of the incrementing UID's. All sequence number based operations are converted to use UID values instead.
## Handling large input
Unfortunately input handling for a single command is not stream based, so everything sent to the server is loaded into memory before being processed. Literal size can be limited though and in this case the server refuses to process literals bigger than configured size.
## SEARCH query
Search query is provided as a tree structure.
Possible SEARCH terms
- Array a list of AND terms
- **or** - in the form of `{key: 'or', value: [terms]}` where _terms_ is a list of OR terms
- **not** - inverts another term. In the form of `{key: 'not', value: term}` where _term_ is the term that must be inverted
- **flag** - describes a flag term. In the form of `{key: 'flag', value: 'term', exists: bool}` where _term_ is the flag name to look for and _bool_ indicates if the flag must be bresent (_true_) or missing (_false_)
- **header** - describes a header value. Header key is a case insensitive exact match (eg. 'X-Foo' matches header 'X-Foo:' but not 'X-Fooz:'). Header value is a partial match. In the form of `{key: 'header', header: 'keyterm', value: 'valueterm'}` where _keyterm_ is the header key name and _valueterm_ is the value of the header. If value is empty then the query acts as boolean, if header key is present, then it matches, otherwise it does not match
- **uid** - is a an array of UID values (numbers)
- **all** - if present then indicates that all messages should match
- **internaldate** - operates on the date the message was received. Date value is day based, so timezone and time should be discarded. In the form of `{key: 'internaldate', operator: 'op', value: 'val'}` where _op_ is one of '<', '=', '>=' and _val_ is a date string
- **date** - operates on the date listed in the massage _Date:_ header. Date value is day based, so timezone and time should be discarded. In the form of `{key: 'date', operator: 'op', value: 'val'}` where _op_ is one of '<', '=', '>=' and _val_ is a date string
- **body** - looks for a partial match in the message BODY (does not match header fields). In the form of `{key: 'body', value: 'term'}` where _term_ is the partial match to look for
- **text** - looks for a partial match in the entire message, including the body and headers. In the form of `{key: 'text', value: 'term'}` where _term_ is the partial match to look for
- **size** - matches message size. In the form of `{key: 'size', value: num, operator: 'op'}` where _op_ is one of '<', '=', '>' and _num_ is the size of the message
- **charset** - sets the charset to be used in the text fields. Can be ignored as everything should be UTF-8 by default
## Currently implemented RFC3501 commands
- **APPEND**
- **CAPABILITY**
- **CHECK**
- **CLOSE**
- **COPY**
- **CREATE**
- **DELETE**
- **EXPUNGE**
- **FETCH**
- **LIST**
- **LOGIN**
- **LOGOUT**
- **LSUB**
- **NOOP**
- **RENAME**
- **SEARCH**
- **SELECT**
- **STARTTLS**
- **STATUS**
- **STORE**
- **SUBSCRIBE**
- **UID COPY**
- **UID STORE**
- **UNSUBSCRIBE**
Extensions
- **Conditional STORE** rfc4551 and **ENABLE**
- **Special-Use Mailboxes** rfc6154
- **ID extension** rfc2971
- **IDLE command** rfc2177
- **NAMESPACE** rfc2342 (hard coded single user namespace)
- **UNSELECT** rfc3691
- **AUTHENTICATE PLAIN** and **SASL-IR**
Unlike the Hoodiecrow project you can not enable or disable extensions, everything is as it is.

543
imap-core/examples/index.js Normal file
View file

@ -0,0 +1,543 @@
'use strict';
// Replace '../index' with 'imap-core' when running this script outside this directory
let IMAPServerModule = require('../index');
let IMAPServer = IMAPServerModule.IMAPServer;
let MemoryNotifier = IMAPServerModule.MemoryNotifier;
const SERVER_PORT = 9993;
const SERVER_HOST = '127.0.0.1';
// Connect to this example server by running
// openssl s_client -crlf -connect localhost:9993
// Username is "testuser" and password is "pass"
// This example uses global folders and subscriptions
let folders = new Map();
let subscriptions = new WeakSet();
// configure initial mailbox state
[
// INBOX
{
path: 'INBOX',
uidValidity: 123,
uidNext: 70,
modifyIndex: 6,
messages: [{
uid: 45,
flags: [],
date: new Date(),
modseq: 1,
raw: Buffer.from('from: sender@example.com\r\nto: to@example.com\r\ncc: cc@example.com\r\nsubject: test')
}, {
uid: 49,
flags: ['\\Seen'],
date: new Date(),
modseq: 2
}, {
uid: 50,
flags: ['\\Seen'],
date: new Date(),
modseq: 3
}, {
uid: 52,
flags: [],
date: new Date(),
modseq: 4
}, {
uid: 53,
flags: [],
date: new Date(),
modseq: 5
}, {
uid: 60,
flags: [],
date: new Date(),
modseq: 6
}],
journal: []
},
// [Gmail]/Sent Mail
{
path: '[Gmail]/Sent Mail',
specialUse: '\\Sent',
uidValidity: 123,
uidNext: 90,
modifyIndex: 0,
messages: [],
journal: []
}
].forEach(folder => {
folders.set(folder.path, folder);
subscriptions.add(folder);
});
// Setup server
let server = new IMAPServer({
secure: true,
id: {
name: 'test'
}
});
// setup notification system for updates
server.notifier = new MemoryNotifier({
folders
});
server.onAuth = function (login, session, callback) {
if (login.username !== 'testuser' || login.password !== 'pass') {
return callback();
}
callback(null, {
user: {
username: login.username
}
});
};
// LIST "" "*"
// Returns all folders, query is informational
// folders is either an Array or a Map
server.onList = function (query, session, callback) {
this.logger.debug('[%s] LIST for "%s"', session.id, query);
callback(null, folders);
};
// LSUB "" "*"
// Returns all subscribed folders, query is informational
// folders is either an Array or a Map
server.onLsub = function (query, session, callback) {
this.logger.debug('[%s] LSUB for "%s"', session.id, query);
let subscribed = [];
folders.forEach(folder => {
if (subscriptions.has(folder)) {
subscribed.push(folder);
}
});
callback(null, subscribed);
};
// SUBSCRIBE "path/to/mailbox"
server.onSubscribe = function (mailbox, session, callback) {
this.logger.debug('[%s] SUBSCRIBE to "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
subscriptions.add(folders.get(mailbox));
callback(null, true);
};
// UNSUBSCRIBE "path/to/mailbox"
server.onUnsubscribe = function (mailbox, session, callback) {
this.logger.debug('[%s] UNSUBSCRIBE from "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
subscriptions.delete(folders.get(mailbox));
callback(null, true);
};
// CREATE "path/to/mailbox"
server.onCreate = function (mailbox, session, callback) {
this.logger.debug('[%s] CREATE "%s"', session.id, mailbox);
if (folders.has(mailbox)) {
return callback(null, 'ALREADYEXISTS');
}
folders.set(mailbox, {
path: mailbox,
uidValidity: Date.now(),
uidNext: 1,
modifyIndex: 0,
messages: [],
journal: []
});
subscriptions.add(folders.get(mailbox));
callback(null, true);
};
// RENAME "path/to/mailbox" "new/path"
// NB! RENAME affects child and hierarchy mailboxes as well, this example does not do this
server.onRename = function (mailbox, newname, session, callback) {
this.logger.debug('[%s] RENAME "%s" to "%s"', session.id, mailbox, newname);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
if (folders.has(newname)) {
return callback(null, 'ALREADYEXISTS');
}
let oldMailbox = folders.get(mailbox);
folders.delete(mailbox);
oldMailbox.path = newname;
folders.set(newname, oldMailbox);
callback(null, true);
};
// DELETE "path/to/mailbox"
server.onDelete = function (mailbox, session, callback) {
this.logger.debug('[%s] DELETE "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
// keep SPECIAL-USE folders
if (folders.get(mailbox).specialUse) {
return callback(null, 'CANNOT');
}
folders.delete(mailbox);
callback(null, true);
};
// SELECT/EXAMINE
server.onOpen = function (mailbox, session, callback) {
this.logger.debug('[%s] Opening "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
return callback(null, {
specialUse: folder.specialUse,
uidValidity: folder.uidValidity,
uidNext: folder.uidNext,
modifyIndex: folder.modifyIndex,
uidList: folder.messages.map(message => message.uid)
});
};
// STATUS (X Y X)
server.onStatus = function (mailbox, session, callback) {
this.logger.debug('[%s] Requested status for "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
return callback(null, {
messages: folder.messages.length,
uidNext: folder.uidNext,
uidValidity: folder.uidValidity,
unseen: folder.messages.filter(message => message.flags.indexOf('\\Seen') < 0).length
});
};
// APPEND mailbox (flags) date message
server.onAppend = function (mailbox, flags, date, raw, session, callback) {
this.logger.debug('[%s] Appending message to "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'TRYCREATE');
}
date = date && new Date(date) || new Date();
let folder = folders.get(mailbox);
let message = {
uid: folder.uidNext++,
date: date && new Date(date) || new Date(),
raw,
flags
};
folder.messages.push(message);
// do not write directly to stream, use notifications as the currently selected mailbox might not be the one that receives the message
this.notifier.addEntries(session.user.username, mailbox, {
command: 'EXISTS',
uid: message.uid
}, () => {
this.notifier.fire(session.user.username, mailbox);
return callback(null, true, {
uidValidity: folder.uidValidity,
uid: message.uid
});
});
};
// STORE / UID STORE, updates flags for selected UIDs
server.onUpdate = function (mailbox, update, session, callback) {
this.logger.debug('[%s] Updating messages in "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
let i = 0;
let processMessages = () => {
if (i >= folder.messages.length) {
this.notifier.fire(session.user.username, mailbox);
return callback(null, true);
}
let message = folder.messages[i++];
let updated = false;
if (update.messages.indexOf(message.uid) < 0) {
return processMessages();
}
switch (update.action) {
case 'set':
// check if update set matches current or is different
if (message.flags.length !== update.value.length || update.value.filter(flag => message.flags.indexOf(flag) < 0).length) {
updated = true;
}
// set flags
message.flags = [].concat(update.value);
break;
case 'add':
message.flags = message.flags.concat(update.value.filter(flag => {
if (message.flags.indexOf(flag) < 0) {
updated = true;
return true;
}
return false;
}));
break;
case 'remove':
message.flags = message.flags.filter(flag => {
if (update.value.indexOf(flag) < 0) {
return true;
}
updated = true;
return false;
});
break;
}
// Onlsy show response if not silent
if (!update.silent) {
session.writeStream.write(session.formatResponse('FETCH', message.uid, {
uid: update.isUid ? message.uid : false,
flags: message.flags
}));
}
// notifiy other clients only if something changed
if (updated) {
this.notifier.addEntries(session.user.username, mailbox, {
command: 'FETCH',
ignore: session.id,
uid: message.uid,
flags: message.flags
}, processMessages);
} else {
processMessages();
}
};
processMessages();
};
// EXPUNGE deletes all messages in selected mailbox marked with \Delete
// EXPUNGE deletes all messages in selected mailbox marked with \Delete
server.onExpunge = function (mailbox, update, session, callback) {
this.logger.debug('[%s] Deleting messages from "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
let deleted = [];
let i, len;
for (i = folder.messages.length - 1; i >= 0; i--) {
if (
(
(update.isUid && update.messages.indexOf(folder.messages[i].uid) >= 0) ||
!update.isUid
) && folder.messages[i].flags.indexOf('\\Deleted') >= 0) {
deleted.unshift(folder.messages[i].uid);
folder.messages.splice(i, 1);
}
}
let entries = [];
for (i = 0, len = deleted.length; i < len; i++) {
entries.push({
command: 'EXPUNGE',
ignore: session.id,
uid: deleted[i]
});
if (!update.silent) {
session.writeStream.write(session.formatResponse('EXPUNGE', deleted[i]));
}
}
this.notifier.addEntries(session.user.username, mailbox, entries, () => {
this.notifier.fire(session.user.username, mailbox);
return callback(null, true);
});
};
// COPY / UID COPY sequence mailbox
server.onCopy = function (mailbox, update, session, callback) {
this.logger.debug('[%s] Copying messages from "%s" to "%s"', session.id, mailbox, update.destination);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
if (!folders.has(update.destination)) {
return callback(null, 'TRYCREATE');
}
let sourceFolder = folders.get(mailbox);
let destinationFolder = folders.get(update.destination);
let messages = [];
let sourceUid = [];
let destinationUid = [];
let i, len;
let entries = [];
for (i = sourceFolder.messages.length - 1; i >= 0; i--) {
if (update.messages.indexOf(sourceFolder.messages[i].uid) >= 0) {
messages.unshift(JSON.parse(JSON.stringify(sourceFolder.messages[i])));
sourceUid.unshift(sourceFolder.messages[i].uid);
}
}
for (i = 0, len = messages.length; i < len; i++) {
messages[i].uid = destinationFolder.uidNext++;
destinationUid.push(messages[i].uid);
destinationFolder.messages.push(messages[i]);
// do not write directly to stream, use notifications as the currently selected mailbox might not be the one that receives the message
entries.push({
command: 'EXISTS',
uid: messages[i].uid
});
}
this.notifier.addEntries(update.destination, session.user.username, entries, () => {
this.notifier.fire(update.destination, session.user.username);
return callback(null, true, {
uidValidity: destinationFolder.uidValidity,
sourceUid,
destinationUid
});
});
};
// sends results to socket
server.onFetch = function (mailbox, options, session, callback) {
this.logger.debug('[%s] Requested FETCH for "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
let entries = [];
if (options.markAsSeen) {
// mark all matching messages as seen
folder.messages.forEach(message => {
if (options.messages.indexOf(message.uid) < 0) {
return;
}
// if BODY[] is touched, then add \Seen flag and notify other clients
if (message.flags.indexOf('\\Seen') < 0) {
message.flags.unshift('\\Seen');
entries.push({
command: 'FETCH',
ignore: session.id,
uid: message.uid,
flags: message.flags
});
}
});
}
this.notifier.addEntries(session.user.username, mailbox, entries, () => {
folder.messages.forEach(message => {
if (options.messages.indexOf(message.uid) < 0) {
return;
}
// send formatted response to socket
session.writeStream.write(session.formatResponse('FETCH', message.uid, {
query: options.query,
values: session.getQueryResponse(options.query, message)
}));
});
// once messages are processed show relevant updates
this.notifier.fire(session.user.username, mailbox);
callback(null, true);
});
};
// returns an array of matching UID values
server.onSearch = function (mailbox, options, session, callback) {
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
let highestModseq = 0;
let uidList = folder.messages.filter(message => {
let match = session.matchSearchQuery(message, options.query);
if (match && highestModseq < message.modseq) {
highestModseq = message.modseq;
}
return match;
}).map(message => message.uid);
callback(null, {
uidList,
highestModseq
});
};
// -------
server.on('error', err => {
console.log('Error occurred\n%s', err.stack); // eslint-disable-line no-console
});
process.on('SIGINT', () => {
server.close(() => {
process.exit();
});
});
// start listening
server.listen(SERVER_PORT, SERVER_HOST);

6
imap-core/index.js Normal file
View file

@ -0,0 +1,6 @@
'use strict';
module.exports.IMAPServer = require('./lib/imap-server').IMAPServer;
module.exports.RedisNotifier = require('./lib/redis-notifier');
module.exports.MemoryNotifier = require('./lib/memory-notifier');
module.exports.imapHandler = require('./lib/handler/imap-handler');

View file

@ -0,0 +1,118 @@
'use strict';
let imapTools = require('../imap-tools');
module.exports = {
state: ['Authenticated', 'Selected'],
// we do not show * EXIST response for added message, so keep other notifications quet as well
// otherwise we might end up in situation where APPEND emits an unrelated * EXISTS response
// which does not yet take into account the appended message
disableNotifications: true,
schema: [{
name: 'mailbox',
type: 'string'
}, {
name: 'flags',
type: 'array',
optional: true
}, {
name: 'datetime',
type: 'string',
optional: true
}, {
name: 'message',
type: 'literal'
}],
handler(command, callback) {
// Check if APPEND method is set
if (typeof this._server.onAppend !== 'function') {
return callback(null, {
response: 'NO',
message: 'APPEND not implemented'
});
}
let mailbox = imapTools.normalizeMailbox((command.attributes.shift() || {}).value);
let message = command.attributes.pop();
let flags = [];
let internaldate = false;
let parsedDate;
if (command.attributes.length === 2) {
flags = command.attributes[0] || [];
internaldate = command.attributes[1] && command.attributes[1].value || '';
} else if (command.attributes.length === 1) {
if (Array.isArray(command.attributes[0])) {
flags = command.attributes[0];
} else {
internaldate = command.attributes[0] && command.attributes[0].value || '';
}
}
flags = flags.map(flag => (flag.value || '').toString());
if (!mailbox) {
return callback(new Error('Invalid mailbox argument for APPEND'));
}
if (!/^literal$/i.test(message.type)) {
return callback(new Error('Invalid message argument for APPEND'));
}
if (internaldate) {
if (!validateInternalDate(internaldate)) {
return callback(new Error('Invalid date argument for APPEND'));
}
parsedDate = new Date(internaldate);
if (parsedDate.toString() === 'Invalid Date' || parsedDate.getTime() > Date.now() + 24 * 3600 * 1000 || parsedDate.getTime() <= 1000) {
return callback(new Error('Invalid date-time argument for APPEND'));
}
}
for (let i = flags.length - 1; i >= 0; i--) {
if (flags[i].charAt(0) === '\\') {
if (imapTools.systemFlags.indexOf(flags[i].toLowerCase()) < 0) {
return callback(new Error('Invalid system flag argument for APPEND'));
} else {
// fix flag case
flags[i] = flags[i].toLowerCase().replace(/^\\./, c => c.toUpperCase());
}
}
}
// keep only unique flags
flags = flags.filter((flag, i) => {
if (i && flags.slice(0, i).indexOf(flag) >= 0) {
return false;
}
return true;
});
this._server.onAppend(mailbox, flags, internaldate, new Buffer(typeof message.value === 'string' ? message.value : (message.value || '').toString(), 'binary'), this.session, (err, success, info) => {
if (err) {
return callback(err);
}
let code = typeof success === 'string' ? success.toUpperCase() : 'APPENDUID ' + info.uidValidity + ' ' + info.uid;
callback(null, {
response: success === true ? 'OK' : 'NO',
code
});
});
}
};
function validateInternalDate(internaldate) {
if (!internaldate || typeof internaldate !== 'string') {
return false;
}
return /^([ \d]\d)\-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\-(\d{4}) (\d{2}):(\d{2}):(\d{2}) ([\-+])(\d{2})(\d{2})$/i.test(internaldate);
}

View file

@ -0,0 +1,88 @@
'use strict';
module.exports = {
state: 'Not Authenticated',
schema: [{
name: 'token',
type: 'string',
optional: true
}],
handler(command, callback, next) {
let token = (command.attributes && command.attributes[0] && command.attributes[0].value || '').toString().trim();
if (!this.secure && !this._server.options.ignoreSTARTTLS) {
// Only allow authentication using TLS
return callback(null, {
response: 'BAD',
message: 'Run STARTTLS first'
});
}
// Check if authentication method is set
if (typeof this._server.onAuth !== 'function') {
return callback(null, {
response: 'NO',
message: 'Authentication not implemented'
});
}
if (!token) {
this._nextHandler = (token, next) => {
this._nextHandler = false;
next(); // keep the parser flowing
authenticate(this, token, callback);
};
this.send('+');
return next(); // resume input parser. Normally this is done by callback() but we need the next input sooner
}
authenticate(this, token, callback);
}
};
function authenticate(connection, token, callback) {
let data = new Buffer(token, 'base64').toString().split('\x00');
if (data.length !== 3) {
return callback(null, {
response: 'BAD',
message: 'Invalid SASL argument'
});
}
let username = (data[1] || '').toString().trim();
let password = (data[2] || '').toString().trim();
// Do auth
connection._server.onAuth({
method: 'PLAIN',
username,
password
}, connection.session, (err, response) => {
if (err) {
connection._server.logger.info('[%s] Authentication error for %s using %s\n%s', connection.id, username, 'PLAIN', err.message);
return callback(err);
}
if (!response || !response.user) {
connection._server.logger.info('[%s] Authentication failed for %s using %s', connection.id, username, 'PLAIN');
return callback(null, {
response: 'NO',
message: 'Authentication failure'
});
}
connection._server.logger.info('[%s] %s authenticated using %s', connection.id, username, 'PLAIN');
connection.session.user = response.user;
connection.state = 'Authenticated';
callback(null, {
response: 'OK',
message: username + ' authenticated'
});
});
}

View file

@ -0,0 +1,39 @@
'use strict';
module.exports = {
handler(command, callback) {
let capabilities = [];
if (!this.secure) {
capabilities.push('STARTTLS');
if (!this._server.options.ignoreSTARTTLS) {
capabilities.push('LOGINDISABLED');
}
capabilities.push('ENABLE');
capabilities.push('CONDSTORE');
}
if (this.state === 'Not Authenticated') {
capabilities.push('AUTH=PLAIN');
capabilities.push('ID');
capabilities.push('SASL-IR');
} else {
capabilities.push('CHILDREN');
capabilities.push('ID');
capabilities.push('IDLE');
capabilities.push('NAMESPACE');
capabilities.push('SPECIAL-USE');
capabilities.push('UIDPLUS');
capabilities.push('UNSELECT');
}
capabilities.sort((a, b) => a.localeCompare(b));
this.send('* CAPABILITY ' + ['IMAP4rev1'].concat(capabilities).join(' '));
callback(null, {
response: 'OK'
});
}
};

View file

@ -0,0 +1,10 @@
'use strict';
module.exports = {
state: ['Selected'],
handler(command, callback) {
callback(null, {
response: 'OK'
});
}
};

View file

@ -0,0 +1,42 @@
'use strict';
module.exports = {
state: 'Selected',
handler(command, callback) {
// Check if EXPUNGE method is set
if (typeof this._server.onExpunge !== 'function') {
return callback(null, {
response: 'NO',
message: 'EXPUNGE not implemented'
});
}
// Just unselect if in read only mode
if (this.selected.readOnly) {
this.session.selected = this.selected = false;
this.state = 'Authenticated';
return callback(null, {
response: 'OK'
});
}
let mailbox = this.selected.mailbox;
this.session.selected = this.selected = false;
this.state = 'Authenticated';
this.updateNotificationListener(() => {
this._server.onExpunge(mailbox, {
isUid: false,
silent: true
}, this.session, () => {
// don't care if expunging succeeded, the mailbox is now closed anyway
callback(null, {
response: 'OK'
});
});
});
}
};

View file

@ -0,0 +1,58 @@
'use strict';
let imapTools = require('../imap-tools');
module.exports = {
state: 'Selected',
schema: [{
name: 'range',
type: 'sequence'
}, {
name: 'mailbox',
type: 'string'
}],
handler(command, callback) {
let cmd = (command.command || '').toString().toUpperCase();
// Check if COPY method is set
if (typeof this._server.onCopy !== 'function') {
return callback(null, {
response: 'NO',
message: cmd + ' not implemented'
});
}
let range = command.attributes[0] && command.attributes[0].value || '';
let mailbox = imapTools.normalizeMailbox(command.attributes[1] && command.attributes[1].value || '');
if (!mailbox) {
return callback(new Error('Invalid mailbox argument for ' + cmd));
}
if (!imapTools.validateSequnce(range)) {
return callback(new Error('Invalid sequence set for ' + cmd));
}
let messages = imapTools.getMessageRange(this.selected.uidList, range, cmd === 'UID COPY');
this._server.onCopy(this.selected.mailbox, {
destination: mailbox,
messages
}, this.session, (err, success, info) => {
if (err) {
return callback(err);
}
let code = typeof success === 'string' ? success.toUpperCase() : 'COPYUID ' + info.uidValidity + ' ' + imapTools.packMessageRange(info.sourceUid) + ' ' + imapTools.packMessageRange(info.destinationUid);
callback(null, {
response: success === true ? 'OK' : 'NO',
code
});
});
}
};

View file

@ -0,0 +1,69 @@
'use strict';
let imapTools = require('../imap-tools');
// tag CREATE "mailbox"
module.exports = {
state: ['Authenticated', 'Selected'],
schema: [{
name: 'mailbox',
type: 'string'
}],
handler(command, callback) {
let mailbox = command.attributes[0] && command.attributes[0].value || '';
// Check if CREATE method is set
if (typeof this._server.onCreate !== 'function') {
return callback(null, {
response: 'NO',
message: 'CREATE not implemented'
});
}
if (!mailbox) {
// nothing to check for if mailbox is not defined
return callback(null, {
response: 'NO',
code: 'CANNOT',
message: 'No folder name given'
});
}
// ignore commands that try to create hierarchy
if (/\/$/.test(mailbox)) {
return callback(null, {
response: 'OK',
code: 'CANNOT',
message: 'Ignoring hierarchy declaration'
});
}
// ignore commands with adjacent spaces
if (/\/{2,}/.test(mailbox)) {
return callback(null, {
response: 'NO',
code: 'CANNOT',
message: 'Adjacent hierarchy separators are not supported'
});
}
mailbox = imapTools.normalizeMailbox(mailbox);
this._server.onCreate(mailbox, this.session, (err, success) => {
if (err) {
return callback(err);
}
callback(null, {
response: success === true ? 'OK' : 'NO',
code: typeof success === 'string' ? success.toUpperCase() : false
});
});
}
};

View file

@ -0,0 +1,69 @@
'use strict';
let imapTools = require('../imap-tools');
// tag DELETE "mailbox"
module.exports = {
state: ['Authenticated', 'Selected'],
schema: [{
name: 'mailbox',
type: 'string'
}],
handler(command, callback) {
let mailbox = command.attributes[0] && command.attributes[0].value || '';
// Check if DELETE method is set
if (typeof this._server.onDelete !== 'function') {
return callback(null, {
response: 'NO',
message: 'DELETE not implemented'
});
}
mailbox = imapTools.normalizeMailbox(mailbox);
if (!mailbox) {
// nothing to check for if mailbox is not defined
return callback(null, {
response: 'NO',
code: 'CANNOT',
message: 'No folder name given'
});
}
if (mailbox === 'INBOX') {
// nothing to check for if mailbox is not defined
return callback(null, {
response: 'NO',
code: 'CANNOT',
message: 'INBOX can not be deleted'
});
}
this._server.onDelete(mailbox, this.session, (err, success) => {
if (err) {
return callback(err);
}
if (success !== true) {
return callback(null, {
response: 'NO',
code: typeof success === 'string' ? success.toUpperCase() : false
});
}
this._server.notifier.fire(this.session.user.username, mailbox, {
action: 'DELETE',
mailbox
});
callback(null, {
response: 'OK'
});
});
}
};

View file

@ -0,0 +1,25 @@
'use strict';
module.exports = {
state: ['Authenticated'],
schema: false,
handler(command, callback) {
let enabled = [];
command.attributes.map(attr => {
// only CONDSTORE is supported for now
if ((attr && attr.value || '').toString().toUpperCase() === 'CONDSTORE') {
this.condstoreEnabled = true;
enabled.push('CONDSTORE');
}
});
this.send('* ENABLED' + (enabled.length ? ' ' : '') + enabled.join(' '));
callback(null, {
response: 'OK',
message: 'Conditional Store enabled'
});
}
};

View file

@ -0,0 +1,36 @@
'use strict';
module.exports = {
state: 'Selected',
handler(command, callback) {
// Check if EXPUNGE method is set
if (typeof this._server.onExpunge !== 'function') {
return callback(null, {
response: 'NO',
message: 'EXPUNGE not implemented'
});
}
// Do nothing if in read only mode
if (this.selected.readOnly) {
return callback(null, {
response: 'OK'
});
}
this._server.onExpunge(this.selected.mailbox, {
isUid: false
}, this.session, (err, success) => {
if (err) {
return callback(err);
}
callback(null, {
response: success === true ? 'OK' : 'NO',
code: typeof success === 'string' ? success.toUpperCase() : false
});
});
}
};

View file

@ -0,0 +1,303 @@
'use strict';
let imapTools = require('../imap-tools');
let imapHandler = require('../handler/imap-handler');
/*
handles both FETCH and UID FETCH
a1 FETCH 1:* (FLAGS BODY BODY.PEEK[HEADER.FIELDS (SUBJECT DATE FROM)] BODY.PEEK[]<0.28> BODY.PEEK[]<0> BODY[HEADER] BODY[1.2])
a1 FETCH 1 (BODY.PEEK[HEADER] BODY.PEEK[TEXT])
a1 FETCH 1 (INTERNALDATE UID RFC822.SIZE FLAGS BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc bcc message-id in-reply-to references)])
*/
module.exports = {
state: 'Selected',
disableNotifications: true,
schema: [{
name: 'range',
type: 'sequence'
}, {
name: 'data',
type: 'mixed'
}, {
name: 'extensions',
type: 'array',
optional: true
}],
handler(command, callback) {
// Check if FETCH method is set
if (typeof this._server.onFetch !== 'function') {
return callback(null, {
response: 'NO',
message: command.command + ' not implemented'
});
}
let isUid = (command.command || '').toString().toUpperCase() === 'UID FETCH' ? true : false;
let range = command.attributes[0] && command.attributes[0].value || '';
if (!imapTools.validateSequnce(range)) {
return callback(new Error('Invalid sequence set for ' + command.command));
}
let messages = imapTools.getMessageRange(this.selected.uidList, range, isUid);
let flagsExist = false;
let uidExist = false;
let modseqExist = false;
let markAsSeen = false;
let metadataOnly = true;
let changedSince = 0;
let query = [];
let params = [].concat(command.attributes[1] || []);
let extensions = [].concat(command.attributes[2] || []).map(val => (val && val.value));
if (extensions.length) {
if (extensions.length !== 2 || (extensions[0] || '').toString().toUpperCase() !== 'CHANGEDSINCE' || isNaN(extensions[1])) {
return callback(new Error('Invalid modifier for ' + command.command));
}
changedSince = Number(extensions[1]);
if (changedSince && !this.selected.condstoreEnabled) {
this.condstoreEnabled = this.selected.condstoreEnabled = true;
}
}
let macros = new Map(
// Map iterator is a list of tuples
[
// ALL
['ALL', ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE']],
// FAST
['FAST', ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE']],
// FULL
['FULL', ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE', 'BODY']]
]
);
let i, len, param, section;
// normalize query
// replace macro with actual items
if (command.attributes[1].type === 'ATOM' && macros.has(command.attributes[1].value.toUpperCase())) {
params = macros.get(command.attributes[1].value.toUpperCase());
}
// checks conditions does the messages need to be marked as seen, is the full body needed etc.
for (i = 0, len = params.length; i < len; i++) {
param = params[i];
if (!param || (typeof param !== 'string' && param.type !== 'ATOM')) {
return callback(new Error('Invalid message data item name for ' + command.command));
}
if (typeof param === 'string') {
param = params[i] = {
type: 'ATOM',
value: param
};
}
if (param.value.toUpperCase() === 'FLAGS') {
flagsExist = true;
}
if (param.value.toUpperCase() === 'UID') {
uidExist = true;
}
if (param.value.toUpperCase() === 'MODSEQ') {
modseqExist = true;
}
if (!this.selected.readOnly) {
if (param.value.toUpperCase() === 'BODY' && param.section) {
// BODY[...]
markAsSeen = true;
} else if (param.value.toUpperCase() === 'RFC822') {
// RFC822
markAsSeen = true;
}
}
if (param.value.toUpperCase() === 'BODY.PEEK' && param.section) {
param.value = 'BODY';
}
if (['BODY', 'RFC822', 'RFC822.SIZE', 'RFC822.HEADER', 'RFC822.TEXT', 'BODYSTRUCTURE'].indexOf(param.value.toUpperCase()) >= 0) {
metadataOnly = false;
}
}
// Adds FLAGS to the response if needed. If the query touches BODY[] then this message
// must be marked as \Seen. To inform the client about flags change, include the updated
// flags in the response
if (markAsSeen && !flagsExist) {
params.push({
type: 'ATOM',
value: 'FLAGS'
});
}
// ensure UID is listed if the command is UID FETCH
if (isUid && !uidExist) {
params.push({
type: 'ATOM',
value: 'UID'
});
}
// ensure MODSEQ is listed if the command uses CHANGEDSINCE modifier
if (changedSince && !modseqExist) {
params.push({
type: 'ATOM',
value: 'MODSEQ'
});
}
// returns header field name from a IMAP command object
let getFieldName = field => (field.value || '').toString().toLowerCase();
// compose query object from parsed IMAP command
for (i = 0, len = params.length; i < len; i++) {
param = params[i];
let item = {
query: imapHandler.compiler({
attributes: param
}),
item: (param.value || '').toString().toLowerCase(),
original: param
};
if (param.section) {
if (!param.section.length) {
item.path = '';
item.type = 'content';
} else {
// we are expecting stuff like 'TEXT' or '1.2.3.TEXT' or '1.2.3'
// the numeric part ('1.2.3') is the path to the MIME node
// and 'TEXT' or '' is the queried item (empty means entire content)
section = (param.section[0].value || '').toString().toLowerCase();
item.path = section.match(/^(\d+\.)*(\d+$)?/);
if (item.path && item.path[0].length) {
item.path = item.path[0].replace(/\.$/, '');
item.type = section.substr(item.path.length + 1) || 'content';
} else {
item.path = isNaN(section) ? '' : section;
item.type = section;
}
/*
item.type = (param.section[0].value || '').toString().toLowerCase();
*/
if (/^HEADER.FIELDS(\.NOT)?$/i.test(item.type) && Array.isArray(param.section[1])) {
item.headers = param.section[1].map(getFieldName);
}
}
// return this element as literal value
item.isLiteral = true;
}
if (['RFC822', 'RFC822.HEADER', 'RFC822.TEXT'].indexOf(param.value.toUpperCase()) >= 0) {
item.isLiteral = true;
}
if (param.partial) {
item.partial = {
startFrom: Number(param.partial[0]) || 0,
maxLength: Number(param.partial[1]) || 0
};
}
if (!imapTools.fetchSchema.hasOwnProperty(item.item) || !checkSchema(imapTools.fetchSchema[item.item], item)) {
return callback(null, {
response: 'BAD',
message: 'Invalid message data item ' + item.query + ' for ' + command.command
});
}
query.push(item);
}
this._server.logger.debug('[%s] FETCH: %s', this.id, JSON.stringify({
metadataOnly: !!metadataOnly,
markAsSeen: !!markAsSeen,
messages,
query,
changedSince,
isUid
}));
this._server.onFetch(this.selected.mailbox, {
metadataOnly: !!metadataOnly,
markAsSeen: !!markAsSeen,
messages,
query,
changedSince,
isUid
}, this.session, (err, success) => {
if (err) {
return callback(err);
}
callback(null, {
response: success === true ? 'OK' : 'NO',
code: typeof success === 'string' ? success.toUpperCase() : false
});
});
}
};
function checkSchema(schema, item) {
let i, len;
if (Array.isArray(schema)) {
for (i = 0, len = schema.length; i < len; i++) {
if (checkSchema(schema[i], item)) {
return true;
}
}
return false;
}
if (schema === true) {
if (item.hasOwnProperty('type') || item.partial) {
return false;
}
return true;
}
if (typeof schema === 'object' && schema) {
// check.type
switch (Object.prototype.toString.call(schema.type)) {
case '[object RegExp]':
if (!schema.type.test(item.type)) {
return false;
}
break;
case '[object String]':
if (schema.type !== item.type) {
return false;
}
break;
case '[object Boolean]':
if (item.hasOwnProperty('type') || item.partial || schema.type !== true) {
return false;
}
break;
default:
return false;
}
// check if headers must be present
if (schema.headers && schema.headers.test(item.type) && !Array.isArray(item.headers)) {
return false;
}
return true;
}
return false;
}

View file

@ -0,0 +1,80 @@
'use strict';
let packageInfo = require('../../../package');
let imapHandler = require('../handler/imap-handler');
let allowedKeys = ['name', 'version', 'os', 'os-version', 'vendor', 'support-url', 'address', 'date', 'command', 'arguments', 'environment'];
module.exports = {
schema: [{
name: 'id',
type: ['null', 'array']
}],
handler(command, callback) {
let clientId = {};
let serverId = {};
let serverIdList = [];
let key = false;
let maxKeyLen = 0;
if (this._server.options.id && typeof this._server.options.id === 'object') {
Object.keys(this._server.options.id).forEach(key => {
serverId[key] = this._server.options.id[key];
});
} else {
serverId.name = packageInfo.name;
serverId.version = packageInfo.version;
serverId.vendor = 'Kreata';
}
// Log ID information proviced by the client
if (Array.isArray(command.attributes[0])) {
command.attributes[0].forEach(val => {
if (key === false) {
key = (val.value || '').toString().toLowerCase().trim();
} else {
if (allowedKeys.indexOf(key) >= 0) {
clientId[key] = (val.value || '').toString();
maxKeyLen = Math.max(maxKeyLen, key.length);
}
key = false;
}
});
this._server.logger.info('[%s] Client identification data received', this.id);
Object.keys(clientId).
sort((a, b) => (allowedKeys.indexOf(a) - allowedKeys.indexOf(b))).
forEach(key => {
this._server.logger.info('[%s] %s%s: %s', this.id, key, new Array(maxKeyLen - key.length + 1).join(' '), clientId[key]);
});
}
// Create response ID serverIdList
if (Object.keys(serverId).length) {
Object.keys(serverId).forEach(key => {
serverIdList.push({
type: 'string',
value: (key || '').toString()
});
serverIdList.push({
type: 'string',
value: (serverId[key] || '').toString()
});
});
}
this.send(imapHandler.compiler({
tag: '*',
command: 'ID',
attributes: serverIdList.length ? [serverIdList] : {
type: 'atom',
value: 'NIL'
}
}));
callback(null, {
response: 'OK'
});
}
};

View file

@ -0,0 +1,46 @@
'use strict';
module.exports = {
state: ['Authenticated', 'Selected'],
handler(command, callback, next) {
let idleTimeout = setTimeout(() => {
if (typeof this._server.onIdleEnd === 'function') {
this._server.onIdleEnd(this.selected && this.selected.mailbox, this.session);
}
this.send('* BYE IDLE terminated');
this.close();
}, this._server.options.idleTimeout || 30 * 60 * 1000);
this._nextHandler = (token, next) => {
this._nextHandler = false;
this.idling = false;
clearTimeout(idleTimeout);
next(); // keep the parser flowing
if (typeof this._server.onIdleEnd === 'function') {
this._server.onIdleEnd(this.selected && this.selected.mailbox, this.session);
}
if (token.toUpperCase().trim() !== 'DONE') {
return callback(new Error('Invalid Idle continuation'));
}
callback(null, {
response: 'OK',
message: 'IDLE terminated'
});
};
this.idling = true;
this.send('+ idling');
this.emitNotifications(); // emit any pending notifications
if (typeof this._server.onIdleStart === 'function') {
this._server.onIdleStart(this.selected && this.selected.mailbox, this.session);
}
return next(); // resume input parser. Normally this is done by callback() but we need the next input sooner
}
};

View file

@ -0,0 +1,141 @@
'use strict';
let imapHandler = require('../handler/imap-handler');
let imapTools = require('../imap-tools');
// tag LIST (SPECIAL-USE) "" "%" RETURN (SPECIAL-USE)
module.exports = {
state: ['Authenticated', 'Selected'],
schema: [{
name: 'selection',
type: ['array'],
optional: true
}, {
name: 'reference',
type: 'string'
}, {
name: 'mailbox',
type: 'string'
}, {
name: 'return',
type: 'atom',
optional: true
}, {
name: 'return',
type: 'array',
optional: true
}],
handler(command, callback) {
let filterSpecialUseFolders = false;
let filterSpecialUseFlags = false;
let reference;
let mailbox;
let arrPos = 0;
// (SPECIAL-USE)
if (Array.isArray(command.attributes[0])) {
if (command.attributes[0].length) {
if (command.attributes[0].length === 1 && command.attributes[0][0].type === 'ATOM' && command.attributes[0][0].value.toUpperCase() === 'SPECIAL-USE') {
filterSpecialUseFolders = true;
} else {
return callback(new Error('Invalid argument provided for LIST'));
}
}
arrPos++;
}
// ""
reference = command.attributes[arrPos] && command.attributes[arrPos].value || '';
arrPos++;
// "%"
mailbox = command.attributes[arrPos] && command.attributes[arrPos].value || '';
arrPos++;
// RETURN (SPECIAL-USE)
if (arrPos < command.attributes.length) {
if (command.attributes[arrPos].type === 'ATOM' && command.attributes[arrPos].value.toUpperCase() === 'RETURN') {
arrPos++;
if (Array.isArray(command.attributes[arrPos]) && command.attributes[arrPos].length === 1 && command.attributes[arrPos][0].type === 'ATOM' && command.attributes[arrPos][0].value.toUpperCase() === 'SPECIAL-USE') {
filterSpecialUseFlags = true;
} else {
return callback(new Error('Invalid argument provided for LIST'));
}
} else {
return callback(new Error('Invalid argument provided for LIST'));
}
}
// Check if LIST method is set
if (typeof this._server.onList !== 'function') {
return callback(null, {
response: 'NO',
message: 'LIST not implemented'
});
}
let query = imapTools.normalizeMailbox(reference + mailbox);
let listResponse = (err, list) => {
if (err) {
return callback(err);
}
imapTools.filterFolders(imapTools.generateFolderListing(list), query).forEach(folder => {
if (!folder) {
return;
}
if (filterSpecialUseFolders && !folder.specialUse) {
return;
}
let response = {
tag: '*',
command: 'LIST',
attributes: []
};
let flags = [];
if (!filterSpecialUseFlags) {
flags = flags.concat(folder.flags || []);
}
flags = flags.concat(folder.specialUse || []);
response.attributes.push(flags.map(flag => ({
type: 'atom',
value: flag
})));
response.attributes.push('/');
response.attributes.push(folder.path);
this.send(imapHandler.compiler(response));
});
callback(null, {
response: 'OK'
});
};
if (!mailbox) {
// return delimiter only
return listResponse(null, [{
path: '/',
flags: '\\Noselect'
}]);
}
// Do folder listing
// Concat reference and mailbox. No special reference handling whatsoever
this._server.onList(query, this.session, listResponse);
}
};

View file

@ -0,0 +1,67 @@
'use strict';
module.exports = {
state: 'Not Authenticated',
schema: [{
name: 'username',
type: 'string'
}, {
name: 'password',
type: 'string'
}],
handler(command, callback) {
let username = (command.attributes[0].value || '').toString().trim();
let password = (command.attributes[1].value || '').toString().trim();
if (!this.secure && !this._server.options.ignoreSTARTTLS) {
// Only allow authentication using TLS
return callback(null, {
response: 'BAD',
message: 'Run STARTTLS first'
});
}
// Check if authentication method is set
if (typeof this._server.onAuth !== 'function') {
this._server.logger.info('[%s] Authentication failed for %s using %s', this.id, username, 'LOGIN');
return callback(null, {
response: 'NO',
message: 'Authentication not implemented'
});
}
// Do auth
this._server.onAuth({
method: 'LOGIN',
username,
password
}, this.session, (err, response) => {
if (err) {
this._server.logger.info('[%s] Authentication error for %s using %s\n%s', this.id, username, 'LOGIN', err.message);
return callback(err);
}
if (!response || !response.user) {
this._server.logger.info('[%s] Authentication failed for %s using %s', this.id, username, 'LOGIN');
return callback(null, {
response: 'NO',
message: 'Authentication failure'
});
}
this._server.logger.info('[%s] %s authenticated using %s', this.id, username, 'LOGIN');
this.session.user = response.user;
this.state = 'Authenticated';
callback(null, {
response: 'OK',
message: username + ' authenticated'
});
});
}
};

View file

@ -0,0 +1,14 @@
'use strict';
module.exports = {
handler(command) {
this.session.selected = this.selected = false;
this.state = 'Logout';
this.updateNotificationListener(() => {
this.send('* BYE Logout requested');
this.send(command.tag + ' OK All dreams are but another reality. Never forget...');
this.close();
});
}
};

View file

@ -0,0 +1,78 @@
'use strict';
let imapHandler = require('../handler/imap-handler');
let imapTools = require('../imap-tools');
// tag LSUB "" "%"
module.exports = {
state: ['Authenticated', 'Selected'],
schema: [{
name: 'reference',
type: 'string'
}, {
name: 'mailbox',
type: 'string'
}],
handler(command, callback) {
let reference = command.attributes[0] && command.attributes[0].value || '';
let mailbox = command.attributes[1] && command.attributes[1].value || '';
// Check if LIST method is set
if (typeof this._server.onLsub !== 'function') {
return callback(null, {
response: 'NO',
message: 'LSUB not implemented'
});
}
let query = imapTools.normalizeMailbox(reference + mailbox);
let lsubResponse = (err, list) => {
if (err) {
return callback(err);
}
imapTools.filterFolders(imapTools.generateFolderListing(list, true), query).forEach(folder => {
if (!folder) {
return;
}
let response = {
tag: '*',
command: 'LSUB',
attributes: [
[].concat(folder.flags || []).map(flag => ({
type: 'atom',
value: flag
})),
'/', folder.path
]
};
this.send(imapHandler.compiler(response));
});
callback(null, {
response: 'OK'
});
};
if (!mailbox) {
// return delimiter only
return lsubResponse(null, {
path: '/',
flags: '\\Noselect'
});
}
// Do folder listing
// Concat reference and mailbox. No special reference handling whatsoever
this._server.onLsub(imapTools.normalizeMailbox(reference + mailbox), this.session, lsubResponse);
}
};

View file

@ -0,0 +1,15 @@
'use strict';
module.exports = {
state: ['Authenticated', 'Selected'],
handler(command, callback) {
// fixed structre
this.send('* NAMESPACE (("" "/")) NIL NIL');
callback(null, {
response: 'OK'
});
}
};

View file

@ -0,0 +1,10 @@
'use strict';
module.exports = {
handler(command, callback) {
callback(null, {
response: 'OK',
message: 'Nothing done'
});
}
};

View file

@ -0,0 +1,82 @@
'use strict';
let imapTools = require('../imap-tools');
// tag RENAME "mailbox"
module.exports = {
state: ['Authenticated', 'Selected'],
schema: [{
name: 'mailbox',
type: 'string'
}, {
name: 'newname',
type: 'string'
}],
handler(command, callback) {
let mailbox = command.attributes[0] && command.attributes[0].value || '';
let newname = command.attributes[1] && command.attributes[1].value || '';
// Check if RENAME method is set
if (typeof this._server.onRename !== 'function') {
return callback(null, {
response: 'NO',
message: 'RENAME not implemented'
});
}
if (!mailbox || !newname) {
// nothing to check for if mailbox is not defined
return callback(null, {
response: 'NO',
code: 'CANNOT',
message: 'No folder name given'
});
}
// ignore commands with adjacent spaces
if (/\/{2,}/.test(newname)) {
return callback(null, {
response: 'NO',
code: 'CANNOT',
message: 'Adjacent hierarchy separators are not supported'
});
}
mailbox = imapTools.normalizeMailbox(mailbox);
newname = imapTools.normalizeMailbox(newname);
// Renaming INBOX is permitted by RFC3501 but not by this implementation
if (mailbox === 'INBOX') {
return callback(null, {
response: 'NO',
code: 'CANNOT',
message: 'INBOX can not be renamed'
});
}
if (newname === 'INBOX') {
return callback(null, {
response: 'NO',
code: 'ALREADYEXISTS',
message: 'INBOX already exists'
});
}
this._server.onRename(mailbox, newname, this.session, (err, success) => {
if (err) {
return callback(err);
}
callback(null, {
response: success === true ? 'OK' : 'NO',
code: typeof success === 'string' ? success.toUpperCase() : false
});
});
}
};

View file

@ -0,0 +1,281 @@
'use strict';
let imapHandler = require('../handler/imap-handler');
let imapTools = require('../imap-tools');
module.exports = {
state: 'Selected',
schema: false, // recursive, can't predefine
handler(command, callback) {
// Check if SEARCH method is set
if (typeof this._server.onSearch !== 'function') {
return callback(null, {
response: 'NO',
message: command.command + ' not implemented'
});
}
let isUid = (command.command || '').toString().toUpperCase() === 'UID SEARCH' ? true : false;
let terms = [];
let getTerms = elements => {
elements.forEach(element => {
if (Array.isArray(element)) {
return getTerms(element);
}
terms.push(element.value);
});
};
getTerms([].concat(command.attributes || []));
let parsed;
try {
parsed = parseQueryTerms(terms, this.selected.uidList);
} catch (E) {
return callback(E);
}
// mark CONDSTORE as enabled
if (parsed.terms.indexOf('modseq') >= 0 && !this.selected.condstoreEnabled) {
this.condstoreEnabled = this.selected.condstoreEnabled = true;
}
this._server.onSearch(this.selected.mailbox, {
query: parsed.query,
terms: parsed.terms,
isUid
}, this.session, (err, results) => {
if (err) {
return callback(err);
}
let matches = results.uidList;
if (typeof matches === 'string') {
return callback(null, {
response: 'NO',
code: matches.toUpperCase()
});
}
let response = {
tag: '*',
command: 'SEARCH',
attributes: []
};
if (Array.isArray(matches) && matches.length) {
matches.sort((a, b) => (a - b));
matches.forEach(nr => {
let seq;
if (!isUid) {
seq = this.selected.uidList.indexOf(nr) + 1;
if (seq) {
response.attributes.push({
type: 'atom',
value: String(seq)
});
}
} else {
response.attributes.push({
type: 'atom',
value: String(nr)
});
}
});
}
// append (MODSEQ 123) for queries that include MODSEQ criteria
if (results.highestModseq && parsed.terms.indexOf('modseq') >= 0) {
response.attributes.push(
[{
type: 'atom',
value: 'MODSEQ'
}, {
type: 'atom',
value: String(results.highestModseq)
}]
);
}
this.send(imapHandler.compiler(response));
return callback(null, {
response: 'OK'
});
});
},
parseQueryTerms // expose for testing
};
function parseQueryTerms(terms, uidList) {
terms = [].concat(terms || []);
let pos = 0;
let term;
let returnTerms = [];
let parsed = {
terms: []
};
let getTerm = level => {
level = level || 0;
if (pos >= terms.length) {
return undefined; // eslint-disable-line no-undefined
}
let term = terms[pos++];
let termType = imapTools.searchSchema[term.toLowerCase()];
let termCount = termType && termType.length;
let curTerm = [term.toLowerCase()];
// MODSEQ is special case as it includes 2 optional arguments
// If the next argument is a number then there is only one argument,
// otherwise there is 3 arguments
if (curTerm[0] === 'modseq') {
termType = isNaN(terms[pos]) ? termType[0] : termType[1];
termCount = termType.length;
}
if (!termType) {
// try if it is a sequence set
if (imapTools.validateSequnce(term)) {
// resolve sequence list to an array of UID values
curTerm = ['uid', imapTools.getMessageRange(uidList, term, false)];
} else {
// no idea what the term is for
throw new Error('Unknown search term ' + term.toUpperCase());
}
} else if (termCount) {
for (let i = 0, len = termCount; i < len; i++) {
if (termType[i] === 'expression') {
curTerm.push(getTerm(level + 1));
} else if (termType[i] === 'sequence') {
if (!imapTools.validateSequnce(terms[pos])) {
throw new Error('Invalid sequence set for ' + term.toUpperCase());
}
// resolve sequence list to an array of UID values
curTerm.push(imapTools.getMessageRange(uidList, terms[pos++], true));
} else {
curTerm.push(terms[pos++]);
}
}
}
if (imapTools.searchMapping.hasOwnProperty(curTerm[0])) {
curTerm = normalizeTerm(curTerm, imapTools.searchMapping[curTerm[0]]);
}
// return multiple values at once, should be already formatted
if (typeof curTerm[0] === 'object') {
return curTerm;
}
// keep a list of used terms
if (parsed.terms.indexOf(curTerm[0]) < 0) {
parsed.terms.push(curTerm[0]);
}
let response = {
key: curTerm[0]
};
switch (response.key) {
case 'not':
// make sure not is not an array, instead return several 'not' expressions
response = [].concat(curTerm[1] || []).map(val => ({
key: 'not',
value: val
}));
if (response.length === 1) {
response = response[0];
}
break;
case 'or':
// ensure that value is alwas an array
response.value = [].concat(curTerm.slice(1) || []);
break;
case 'header':
response.header = (curTerm[1] || '').toString().toLowerCase();
response.value = (curTerm[2] || '').toString(); // empty header value means that the header key must be present
break;
case 'date':
case 'internaldate':
response.operator = curTerm[1];
response.value = (curTerm[2] || '').toString();
break;
case 'size':
if (isNaN(curTerm[2])) {
throw new Error('Invalid size argument for ' + response.key);
}
response.operator = curTerm[1];
response.value = Number(curTerm[2]) || 0;
break;
case 'modseq':
if (isNaN(curTerm[curTerm.length - 1])) {
throw new Error('Invalid MODSEQ argument');
}
response.value = Number(curTerm[curTerm.length - 1]) || 0;
break;
default:
if (curTerm.length) {
response.value = curTerm.length > 2 ? curTerm.slice(1) : curTerm[1];
}
}
return response;
};
while (typeof (term = getTerm()) !== 'undefined') {
if (Array.isArray(term)) {
// flatten arrays
returnTerms = returnTerms.concat(term);
} else {
returnTerms.push(term);
}
}
parsed.terms.sort();
parsed.query = returnTerms;
return parsed;
}
function normalizeTerm(term, mapping) {
let flags;
let result = [mapping.key].concat(mapping.value.map(val => (val === '$1' ? term[1] : val)));
if (result[0] === 'flag') {
flags = [];
result.forEach((val, i) => {
if (i && (i % 2 !== 0)) {
flags.push({
key: 'flag',
value: val,
exists: !!result[i + 1]
});
}
});
return flags;
}
return result;
}

View file

@ -0,0 +1,214 @@
'use strict';
let imapHandler = require('../handler/imap-handler');
let imapTools = require('../imap-tools');
// tag SELECT "mailbox"
// tag EXAMINE "mailbox"
module.exports = {
state: ['Authenticated', 'Selected'],
schema: [{
name: 'mailbox',
type: 'string'
}, {
name: 'extensions',
type: 'array',
optional: true
}],
handler(command, callback) {
let mailbox = imapTools.normalizeMailbox(command.attributes[0] && command.attributes[0].value || '');
let extensions = [].
concat(command.attributes[1] || []).
map(attr => (attr && attr.value || '').toString().toUpperCase());
// Is CONDSTORE found from the optional arguments list?
if (extensions.indexOf('CONDSTORE') >= 0) {
this.condstoreEnabled = true;
}
if (typeof this._server.onOpen !== 'function') {
return callback(null, {
response: 'NO',
message: command.command + ' not implemented'
});
}
if (!mailbox) {
// nothing to check for if mailbox is not defined
return callback(null, {
response: 'NO',
code: 'NONEXISTENT'
});
}
this._server.onOpen(mailbox, this.session, (err, folder) => {
if (err) {
this.session.selected = this.selected = false;
this.state = 'Authenticated';
return callback(err);
}
if (!folder || typeof folder === 'string') {
this.session.selected = this.selected = false;
this.state = 'Authenticated';
return callback(null, {
response: 'NO',
code: typeof folder === 'string' ? folder : 'NONEXISTENT'
});
}
// Set current state as selected
this.session.selected = this.selected = {
modifyIndex: folder.modifyIndex,
uidList: folder.uidList,
notifications: [],
condstoreEnabled: this.condstoreEnabled,
readOnly: (command.command || '').toString().toUpperCase() === 'EXAMINE' ? true : false,
mailbox
};
this.state = 'Selected';
// * FLAGS (\Answered \Flagged \Draft \Deleted \Seen)
this.send(imapHandler.compiler({
tag: '*',
command: 'FLAGS',
attributes: [
[{
type: 'atom',
value: '\\Answered'
}, {
type: 'atom',
value: '\\Flagged'
}, {
type: 'atom',
value: '\\Draft'
}, {
type: 'atom',
value: '\\Deleted'
}, {
type: 'atom',
value: '\\Seen'
}]
]
}));
// * OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen \*)] Flags permitted
this.send(imapHandler.compiler({
tag: '*',
command: 'OK',
attributes: [{
type: 'section',
section: [
// unrelated comment to enforce eslint-happy indentation
{
type: 'atom',
value: 'PERMANENTFLAGS'
},
[{
type: 'atom',
value: '\\Answered'
}, {
type: 'atom',
value: '\\Flagged'
}, {
type: 'atom',
value: '\\Draft'
}, {
type: 'atom',
value: '\\Deleted'
}, {
type: 'atom',
value: '\\Seen'
}, {
type: 'text',
value: '\\*'
}]
]
}, {
type: 'text',
value: 'Flags permitted'
}]
}));
// * OK [UIDVALIDITY 123] UIDs valid
this.send(imapHandler.compiler({
tag: '*',
command: 'OK',
attributes: [{
type: 'section',
section: [{
type: 'atom',
value: 'UIDVALIDITY'
}, {
type: 'atom',
value: String(Number(folder.uidValidity) || 1)
}]
}, {
type: 'text',
value: 'UIDs valid'
}]
}));
// * 0 EXISTS
this.send('* ' + folder.uidList.length + ' EXISTS');
// * 0 RECENT
this.send('* 0 RECENT');
// * OK [HIGHESTMODSEQ 123]
if ('modifyIndex' in folder && Number(folder.modifyIndex)) {
this.send(imapHandler.compiler({
tag: '*',
command: 'OK',
attributes: [{
type: 'section',
section: [{
type: 'atom',
value: 'HIGHESTMODSEQ'
}, {
type: 'atom',
value: String(Number(folder.modifyIndex) || 0)
}]
}, {
type: 'text',
value: 'Highest'
}]
}));
}
// * OK [UIDNEXT 1] Predicted next UID
this.send(imapHandler.compiler({
tag: '*',
command: 'OK',
attributes: [{
type: 'section',
section: [{
type: 'atom',
value: 'UIDNEXT'
}, {
type: 'atom',
value: String(Number(folder.uidNext) || 1)
}]
}, {
type: 'text',
value: 'Predicted next UID'
}]
}));
// start listening for EXPUNGE, EXISTS and FETCH FLAGS notifications
this.updateNotificationListener(() => {
callback(null, {
response: 'OK',
code: this.selected.readOnly ? 'READ-ONLY' : 'READ-WRITE',
message: command.command + ' completed' + (this.selected.condstoreEnabled ? ', CONDSTORE is now enabled' : '')
});
});
});
}
};

View file

@ -0,0 +1,70 @@
'use strict';
// openssl s_client -starttls imap -crlf -connect localhost:1143
let tls = require('tls');
let tlsOptions = require('../tls-options');
let SOCKET_TIMEOUT = 30 * 60 * 1000;
module.exports = {
handler(command, callback) {
if (this.secure) {
return callback(null, {
response: 'NO',
message: 'Connection is already secured'
});
}
setImmediate(upgrade.bind(null, this));
callback(null, {
response: 'OK'
});
}
};
/**
* Upgrades current socket to use TLS
* @param {Object} connection IMAPConnection instance
*/
function upgrade(connection) {
connection._socket.unpipe(connection._parser);
connection.writeStream.unpipe(connection._socket);
connection._upgrading = true;
let secureContext = tls.createSecureContext(tlsOptions(connection._server.options));
let socketOptions = {
isServer: true,
secureContext
};
// Apply additional socket options if these are set in the server options
['requestCert', 'rejectUnauthorized', 'session'].forEach(key => {
if (key in connection._server.options) {
socketOptions[key] = connection._server.options[key];
}
});
// remove all listeners from the original socket besides the error handler
connection._socket.removeAllListeners();
connection._socket.on('error', connection._onError.bind(connection));
// upgrade connection
let secureSocket = new tls.TLSSocket(connection._socket, socketOptions);
secureSocket.on('close', connection._onClose.bind(connection));
secureSocket.on('error', connection._onError.bind(connection));
secureSocket.on('clientError', connection._onError.bind(connection));
secureSocket.setTimeout(connection._server.options.socketTimeout || SOCKET_TIMEOUT, connection._onTimeout.bind(connection));
secureSocket.on('secure', () => {
connection.secure = true;
connection._socket = secureSocket;
connection._upgrading = false;
connection._server.logger.info('[%s] Connection upgraded to TLS', connection.id);
connection._socket.pipe(connection._parser);
connection.writeStream.pipe(connection._socket);
});
}

View file

@ -0,0 +1,135 @@
'use strict';
let imapTools = require('../imap-tools');
let imapHandler = require('../handler/imap-handler');
// tag STATUS "mailbox" (UNSEEN UIDNEXT)
module.exports = {
state: ['Authenticated', 'Selected'],
schema: [{
name: 'mailbox',
type: 'string'
}, {
name: 'query',
type: 'array'
}],
handler(command, callback) {
let mailbox = command.attributes[0] && command.attributes[0].value || '';
let query = command.attributes[1] && command.attributes[1];
let statusElements = ['MESSAGES', 'RECENT', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN', 'HIGHESTMODSEQ'];
let statusItem;
let statusQuery = [];
// Check if STATUS method is set
if (typeof this._server.onStatus !== 'function') {
return callback(null, {
response: 'NO',
message: 'STATUS not implemented'
});
}
if (!mailbox) {
// nothing to check for if mailbox is not defined
return callback(null, {
response: 'NO',
code: 'CANNOT',
message: 'No folder name given'
});
}
if (!Array.isArray(query)) {
return callback(null, {
response: 'BAD',
message: 'Invalid arguments for STATUS'
});
}
// check if status elements are listed
if (!query.length) {
return callback(null, {
response: 'BAD',
message: 'Empty status list'
});
}
// check if only known status items are used
for (let i = 0, len = query.length; i < len; i++) {
statusItem = (query[i] && query[i].value || '').toString().toUpperCase();
if (statusElements.indexOf(statusItem) < 0) {
return callback(null, {
response: 'BAD',
message: 'Invalid status items'
});
}
if (statusQuery.indexOf(statusItem) < 0) {
statusQuery.push(statusItem);
}
}
mailbox = imapTools.normalizeMailbox(mailbox);
// mark CONDSTORE as enabled
if (statusQuery.indexOf('HIGHESTMODSEQ') >= 0 && !this.condstoreEnabled) {
this.condstoreEnabled = true;
if (this.selected) {
this.selected.condstoreEnabled = true;
}
}
this._server.onStatus(mailbox, this.session, (err, data) => {
let response;
let values = {
RECENT: 0
};
if (err) {
return callback(err);
}
if (typeof data === 'string') {
return callback(null, {
response: 'NO',
code: data.toUpperCase()
});
}
if (data) {
response = {
tag: '*',
command: 'STATUS',
attributes: [
command.attributes[0], // reuse the mailbox declaration from client command
[]
]
};
Object.keys(data).forEach(key => {
values[key.toUpperCase()] = (data[key] || '').toString();
});
statusQuery.forEach(key => {
response.attributes[1].push({
type: 'atom',
value: key.toUpperCase()
});
response.attributes[1].push({
type: 'atom',
value: (values[key] || '0').toString()
});
});
this.send(imapHandler.compiler(response));
}
callback(null, {
response: 'OK'
});
});
}
};

View file

@ -0,0 +1,169 @@
'use strict';
let imapTools = require('../imap-tools');
module.exports = {
state: 'Selected',
disableNotifications: true,
schema: [{
name: 'range',
type: 'sequence'
}, {
name: 'extensions',
type: 'array',
optional: true
}, {
name: 'action',
type: 'string'
}, {
name: 'flags',
type: 'array'
}],
handler(command, callback) {
// Check if STORE method is set
if (typeof this._server.onUpdate !== 'function') {
return callback(null, {
response: 'NO',
message: 'STORE not implemented'
});
}
// Do nothing if in read only mode
if (this.selected.readOnly) {
return callback(null, {
response: 'OK',
message: 'STORE ignored with read-only mailbox'
});
}
let type = 'flags'; // currently hard coded, in the future might support other values as well, eg. X-GM-LABELS
let range = command.attributes[0] && command.attributes[0].value || '';
// if arguments include extenstions at index 1, then length is 4, otherwise 3
let pos = command.attributes.length === 4 ? 1 : 0;
let action = (command.attributes[pos + 1] && command.attributes[pos + 1].value || '').toString().toUpperCase();
let flags = [].
concat(command.attributes[pos + 2] || []).
map(flag => (flag && flag.value || '').toString());
let unchangedSince = 0;
let silent = false;
// extensions are available as the optional argument at index 1
let extensions = !pos ? [] : [].
concat(command.attributes[pos] || []).
map(val => (val && val.value));
if (extensions.length) {
if (extensions.length !== 2 || (extensions[0] || '').toString().toUpperCase() !== 'UNCHANGEDSINCE' || isNaN(extensions[1])) {
return callback(new Error('Invalid modifier for STORE'));
}
unchangedSince = Number(extensions[1]);
if (unchangedSince && !this.selected.condstoreEnabled) {
this.condstoreEnabled = this.selected.condstoreEnabled = true;
}
}
if (action.substr(-7) === '.SILENT') {
action = action.substr(0, action.length - 7);
silent = true;
}
if (!imapTools.validateSequnce(range)) {
return callback(new Error('Invalid sequence set for STORE'));
}
if (!/^[\-+]?FLAGS$/.test(action)) {
return callback(new Error('Invalid message data item name for STORE'));
}
switch (action.charAt(0)) {
case '+':
action = 'add';
break;
case '-':
action = 'remove';
break;
default:
action = 'set';
}
for (let i = flags.length - 1; i >= 0; i--) {
if (flags[i].charAt(0) === '\\') {
if (imapTools.systemFlags.indexOf(flags[i].toLowerCase()) < 0) {
return callback(new Error('Invalid system flag argument for STORE'));
} else {
// fix flag case
flags[i] = flags[i].toLowerCase().replace(/^\\./, c => c.toUpperCase());
}
}
}
// keep only unique flags
flags = flags.filter((flag, i) => {
if (i && flags.slice(0, i).indexOf(flag) >= 0) {
return false;
}
return true;
});
let messages = imapTools.getMessageRange(this.selected.uidList, range, false);
this._server.onUpdate(this.selected.mailbox, {
value: flags,
action,
type,
silent,
messages,
unchangedSince
}, this.session, (err, success, modified) => {
if (err) {
return callback(err);
}
// STORE returns MODIFIED as sequence numbers, so convert UIDs to sequence list
if (modified && modified.length) {
modified = modified.
map(uid => this.selected.uidList.indexOf(uid) + 1).
filter(seq =>
// ensure that deleted items (eg seq=0) do not end up in the list
seq > 0
);
}
let message = success === true ? 'STORE completed' : false;
if (modified && modified.length) {
message = 'Conditional STORE failed';
} else if (message && unchangedSince) {
message = 'Conditional STORE completed';
}
let response = {
response: success === true ? 'OK' : 'NO',
code: typeof success === 'string' ? success.toUpperCase() : (modified && modified.length ? 'MODIFIED ' + imapTools.packMessageRange(modified) : false),
message
};
// check if only messages that exist are referenced
if (!this._server.options.allowStoreExpunged && success === true && !silent && messages.length) {
for (let i = this.selected.notifications.length - 1; i >= 0; i--) {
if (this.selected.notifications[i].command === 'EXPUNGE' && messages.indexOf(this.selected.notifications[i].uid) >= 0) {
response = {
response: 'NO',
message: 'Some of the messages no longer exist'
};
break;
}
}
}
callback(null, response);
});
}
};

View file

@ -0,0 +1,54 @@
'use strict';
let imapTools = require('../imap-tools');
// tag SUBSCRIBE "mailbox"
module.exports = {
state: ['Authenticated', 'Selected'],
schema: [{
name: 'mailbox',
type: 'string'
}],
handler(command, callback) {
let mailbox = imapTools.normalizeMailbox(command.attributes[0] && command.attributes[0].value || '');
// Check if SUBSCRIBE method is set
if (typeof this._server.onSubscribe !== 'function') {
return callback(null, {
response: 'NO',
message: 'SUBSCRIBE not implemented'
});
}
if (!mailbox) {
// nothing to check for if mailbox is not defined
return callback(null, {
response: 'NO',
code: 'NONEXISTENT'
});
}
if (mailbox === 'INBOX') {
return callback(null, {
response: 'OK'
});
}
this._server.onSubscribe(mailbox, this.session, (err, success) => {
if (err) {
return callback(err);
}
callback(null, {
response: success === true ? 'OK' : 'NO',
code: typeof success === 'string' ? success.toUpperCase() : false
});
});
}
};

View file

@ -0,0 +1,50 @@
'use strict';
let imapTools = require('../imap-tools');
module.exports = {
state: 'Selected',
schema: [{
name: 'range',
type: 'sequence'
}],
handler(command, callback) {
// Check if EXPUNGE method is set
if (typeof this._server.onExpunge !== 'function') {
return callback(null, {
response: 'NO',
message: 'EXPUNGE not implemented'
});
}
// Do nothing if in read only mode
if (this.selected.readOnly) {
return callback(null, {
response: 'OK'
});
}
let range = command.attributes[0] && command.attributes[0].value || '';
if (!imapTools.validateSequnce(range)) {
return callback(new Error('Invalid sequence set for UID EXPUNGE'));
}
let messages = imapTools.getMessageRange(this.selected.uidList, range, true);
this._server.onExpunge(this.selected.mailbox, {
isUid: true,
messages
}, this.session, (err, success) => {
if (err) {
return callback(err);
}
callback(null, {
response: success === true ? 'OK' : 'NO',
code: typeof success === 'string' ? success.toUpperCase() : false
});
});
}
};

View file

@ -0,0 +1,136 @@
'use strict';
let imapTools = require('../imap-tools');
module.exports = {
state: 'Selected',
schema: [{
name: 'range',
type: 'sequence'
}, {
name: 'extensions',
type: 'array',
optional: true
}, {
name: 'action',
type: 'string'
}, {
name: 'flags',
type: 'array'
}],
handler(command, callback) {
// Check if STORE method is set
if (typeof this._server.onUpdate !== 'function') {
return callback(null, {
response: 'NO',
message: 'STORE not implemented'
});
}
let type = 'flags'; // currently hard coded, in the future might support other values as well, eg. X-GM-LABELS
let range = command.attributes[0] && command.attributes[0].value || '';
// if arguments include extenstions at index 1, then length is 4, otherwise 3
let pos = command.attributes.length === 4 ? 1 : 0;
let action = (command.attributes[pos + 1] && command.attributes[pos + 1].value || '').toString().toUpperCase();
let flags = [].
concat(command.attributes[pos + 2] || []).
map(flag => (flag && flag.value || '').toString());
let unchangedSince = 0;
let silent = false;
// extensions are available as the optional argument at index 1
let extensions = !pos ? [] : [].
concat(command.attributes[pos] || []).
map(val => (val && val.value));
if (extensions.length) {
if (extensions.length !== 2 || (extensions[0] || '').toString().toUpperCase() !== 'UNCHANGEDSINCE' || isNaN(extensions[1])) {
return callback(new Error('Invalid modifier for STORE'));
}
unchangedSince = Number(extensions[1]);
if (unchangedSince && !this.selected.condstoreEnabled) {
this.condstoreEnabled = this.selected.condstoreEnabled = true;
}
}
if (action.substr(-7) === '.SILENT') {
action = action.substr(0, action.length - 7);
silent = true;
}
if (!imapTools.validateSequnce(range)) {
return callback(new Error('Invalid sequence set for UID STORE'));
}
if (!/^[\-+]?FLAGS$/.test(action)) {
return callback(new Error('Invalid message data item name for UID STORE'));
}
switch (action.charAt(0)) {
case '+':
action = 'add';
break;
case '-':
action = 'remove';
break;
default:
action = 'set';
}
for (let i = flags.length - 1; i >= 0; i--) {
if (flags[i].charAt(0) === '\\') {
if (imapTools.systemFlags.indexOf(flags[i].toLowerCase()) < 0) {
return callback(new Error('Invalid system flag argument for UID STORE'));
} else {
// fix flag case
flags[i] = flags[i].toLowerCase().replace(/^\\./, c => c.toUpperCase());
}
}
}
// keep only unique flags
flags = flags.filter((flag, i) => {
if (i && flags.slice(0, i).indexOf(flag) >= 0) {
return false;
}
return true;
});
let messages = imapTools.getMessageRange(this.selected.uidList, range, true);
this._server.onUpdate(this.selected.mailbox, {
isUid: true,
value: flags,
action,
type,
silent,
messages,
unchangedSince
}, this.session, (err, success, modified) => {
if (err) {
return callback(err);
}
let message = success === true ? 'UID STORE completed' : false;
if (modified && modified.length) {
message = 'Conditional UID STORE failed';
} else if (message && unchangedSince) {
message = 'Conditional UID STORE completed';
}
callback(null, {
response: success === true ? 'OK' : 'NO',
code: typeof success === 'string' ? success.toUpperCase() : (modified && modified.length ? 'MODIFIED ' + imapTools.packMessageRange(modified) : false),
message
});
});
}
};

View file

@ -0,0 +1,16 @@
'use strict';
module.exports = {
state: 'Selected',
handler(command, callback) {
this.session.selected = this.selected = false;
this.state = 'Authenticated';
this.updateNotificationListener(() => {
callback(null, {
response: 'OK'
});
});
}
};

View file

@ -0,0 +1,55 @@
'use strict';
let imapTools = require('../imap-tools');
// tag UNSUBSCRIBE "mailbox"
module.exports = {
state: ['Authenticated', 'Selected'],
schema: [{
name: 'mailbox',
type: 'string'
}],
handler(command, callback) {
let mailbox = imapTools.normalizeMailbox(command.attributes[0] && command.attributes[0].value || '');
// Check if UNSUBSCRIBE method is set
if (typeof this._server.onUnsubscribe !== 'function') {
return callback(null, {
response: 'NO',
message: 'UNSUBSCRIBE not implemented'
});
}
if (!mailbox) {
// nothing to check for if mailbox is not defined
return callback(null, {
response: 'NO',
code: 'NONEXISTENT'
});
}
if (mailbox === 'INBOX') {
return callback(null, {
response: 'NO',
message: 'Can not unsubscribe from INBOX'
});
}
this._server.onUnsubscribe(mailbox, this.session, (err, success) => {
if (err) {
return callback(err);
}
callback(null, {
response: success === true ? 'OK' : 'NO',
code: typeof success === 'string' ? success.toUpperCase() : false
});
});
}
};

View file

@ -0,0 +1,151 @@
# IMAP Handler
Server specific fork of [emailjs-imap-handler](https://github.com/emailjs/emailjs-imap-handler) for Node.js (v5+). Mostly differs from the upstream in the behavior for compiling instead of compiling a command into long string, a Stream object is returned that can be piped directly to socket. Goal is to pass around large messages as streams instead of keeping these in memory.
This is more suitable for servers than clients as it is currently not possible to pause the output stream to wait for '+' tagged server response for literal values.
## Usage
```javascript
let handler = require('./lib/handler/imap-handler');
```
### Parse IMAP commands
To parse a command you need to have the command as one complete string (including all literals) without the ending &lt;CR&gt;&lt;LF&gt;
imapHandler.parser(imapCommand);
Where
* **imapCommand** is an IMAP string without the final line break
The function returns an object in the following form:
```javascript
{
tag: "TAG",
command: "COMMAND",
attributes: [
{type: "SEQUENCE", value: "sequence-set"},
{type: "ATOM", value: "atom", section:[section_elements], partial: [start, end]},
{type: "STRING", value: "string"},
{type: "LITERAL", value: "literal"},
[list_elements]
]
}
```
Where
* **tag** is a string containing the tag
* **command** is the first element after tag
* **attributes** (if present) is an array of next elements
If section or partial values are not specified in the command, the values are also missing from the ATOM element
**NB!** Sequence numbers are identified as ATOM values if the value contains only numbers.
**NB!** NIL atoms are always identified as `null` values, even though in some cases it might be an ATOM with value `"NIL"`
For example
```javascript
let imapHandler = require("imap-handler-1");
imapHandler.parser("A1 FETCH *:4 (BODY[HEADER.FIELDS ({4}\r\nDate Subject)]<12.45> UID)");
```
Results in the following value:
```json
{
"tag": "A1",
"command": "FETCH",
"attributes": [
[
{
"type": "SEQUENCE",
"value": "*:4"
},
{
"type": "ATOM",
"value": "BODY",
"section": [
{
"type": "ATOM",
"value": "HEADER.FIELDS"
},
[
{
"type": "LITERAL",
"value": "Date"
},
{
"type": "ATOM",
"value": "Subject"
}
]
],
"partial": [
12,
45
]
},
{
"type": "ATOM",
"value": "UID"
}
]
]
}
```
### Compile command objects into IMAP commands
You can "compile" parsed or self generated IMAP command objects to IMAP command strings with
imapHandler.compileStream(commandObject, isLogging);
Where
* **commandObject** is an object parsed with `imapHandler.parser()` or self generated
* **isLogging** if set to true, do not include literals and long strings, useful when logging stuff and do not want to include message bodies etc. Additionally nodes with `sensitive: true` options are also not displayed (useful with logging passwords) if `logging` is used.
The function returns a Stream.
The input object differs from the parsed object with the following aspects:
* **string**, **number** and **null** (null values are all non-number and non-string falsy values) are allowed to use directly - `{type: "STRING", value: "hello"}` can be replaced with `"hello"`
* Additional types are used: `SECTION` which is an alias for `ATOM` and `TEXT` which returns the input string as given with no modification (useful for server messages).
* **LITERAL** can takes streams as values. You do need to know the expected length beforehand though `{type:'LITERAL', expectedLength: 1024, value: stream}`. If the provided length does not match actual stream output length, then the output is either truncated or padded with space symbols to match the expected length.
```javascript
{
type: 'LITERAL',
value: stream,
expectedLength: 100, // full stream length
startFrom: 10, // optional start marker, do not emit bytes before it
maxLength: 30 // optional length of the output stream
}
```
For example
```javascript
let command = {
tag: "*",
command: "OK",
attributes: [
{
type: "SECTION",
section: [
{type: "ATOM", value: "ALERT"}
]
},
{type:"TEXT", value: "NB! The server is shutting down"}
]
};
imapHandler.compileStream(command).pipe(process.stdout);
// * OK [ALERT] NB! The server is shutting down
```

View file

@ -0,0 +1,253 @@
/* eslint no-console: 0, new-cap: 0 */
'use strict';
let imapFormalSyntax = require('./imap-formal-syntax');
let streams = require('stream');
let PassThrough = streams.PassThrough;
let LengthLimiter = require('../length-limiter');
/**
* Compiles an input object into
*/
module.exports = function (response, isLogging) {
let output = new PassThrough();
let resp = (response.tag || '') + (response.command ? ' ' + response.command : '');
let lr = resp; // this value is going to store last known `resp` state for later usage
let val, lastType;
let waiting = false;
let queue = [];
let ended = false;
let emit = function (stream, expectedLength, startFrom, maxLength) {
expectedLength = expectedLength || 0;
startFrom = startFrom || 0;
maxLength = maxLength || 0;
if (resp.length) {
queue.push(new Buffer(resp, 'binary'));
lr = resp;
resp = '';
}
if (stream) {
queue.push({
type: 'stream',
stream,
expectedLength,
startFrom,
maxLength
});
}
if (waiting) {
return;
}
if (!queue.length) {
if (ended) {
output.end();
}
return;
}
let value = queue.shift();
if (value.type === 'stream') {
if (!value.expectedLength) {
return emit();
}
waiting = true;
let expectedLength = value.maxLength ? Math.min(value.expectedLength, value.startFrom + value.maxLength) : value.expectedLength;
let startFrom = value.startFrom;
let limiter = new LengthLimiter(expectedLength, ' ', startFrom);
value.stream.pipe(limiter).pipe(output, {
end: false
});
// pass errors to output
value.stream.on('error', err => {
output.emit('error', err);
});
limiter.on('end', () => {
waiting = false;
return emit();
});
} else if (value instanceof Buffer) {
output.write(value);
return emit();
} else {
if (typeof value === 'number') {
value = value.toString();
} else if (typeof value !== 'string') {
value = (value || '').toString();
}
output.write(new Buffer(value, 'binary'));
return emit();
}
};
let walk = function (node, callback) {
if (lastType === 'LITERAL' || (['(', '<', '['].indexOf((resp || lr).substr(-1)) < 0 && (resp || lr).length)) {
resp += ' ';
}
if (Array.isArray(node)) {
lastType = 'LIST';
resp += '(';
let pos = 0;
let next = () => {
if (pos >= node.length) {
resp += ')';
return setImmediate(callback);
}
walk(node[pos++], next);
};
return setImmediate(next);
}
if (!node && typeof node !== 'string' && typeof node !== 'number') {
resp += 'NIL';
return setImmediate(callback);
}
if (typeof node === 'string') {
if (isLogging && node.length > 20) {
resp += '"(* ' + node.length + 'B string *)"';
} else {
resp += JSON.stringify(node);
}
return setImmediate(callback);
}
if (typeof node === 'number') {
resp += Math.round(node) || 0; // Only integers allowed
return setImmediate(callback);
}
lastType = node.type;
if (isLogging && node.sensitive) {
resp += '"(* value hidden *)"';
return setImmediate(callback);
}
switch (node.type.toUpperCase()) {
case 'LITERAL':
{
let nval = node.value;
if (typeof nval === 'number') {
nval = nval.toString();
}
let len;
if (nval && typeof nval.pipe === 'function') {
len = node.expectedLength || 0;
if (node.startFrom) {
len -= node.startFrom;
}
if (node.maxLength) {
len = Math.min(len, node.maxLength);
}
} else {
len = (nval || '').toString().length;
}
if (isLogging) {
resp += '"(* ' + len + 'B literal *)"';
} else {
resp += '{' + len + '}\r\n';
emit();
if (nval && typeof nval.pipe === 'function') {
//value is a stream object
emit(nval, node.expectedLength, node.startFrom, node.maxLength);
} else {
resp = nval || '';
}
}
break;
}
case 'STRING':
if (isLogging && node.value.length > 20) {
resp += '"(* ' + node.value.length + 'B string *)"';
} else {
resp += JSON.stringify(node.value || '');
}
break;
case 'TEXT':
case 'SEQUENCE':
resp += node.value || '';
break;
case 'NUMBER':
resp += (node.value || 0);
break;
case 'ATOM':
case 'SECTION':
{
val = node.value || '';
if (imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
val = JSON.stringify(val);
}
resp += val;
let finalize = () => {
if (node.partial) {
resp += '<' + node.partial.join('.') + '>';
}
setImmediate(callback);
};
if (node.section) {
resp += '[';
let pos = 0;
let next = () => {
if (pos >= node.section.length) {
resp += ']';
return setImmediate(finalize);
}
walk(node.section[pos++], next);
};
return setImmediate(next);
}
return finalize();
}
}
setImmediate(callback);
};
let finalize = () => {
ended = true;
emit();
};
let pos = 0;
let attribs = [].concat(response.attributes || []);
let next = () => {
if (pos >= attribs.length) {
return setImmediate(finalize);
}
walk(attribs[pos++], next);
};
setImmediate(next);
return output;
};
// expose for testing
module.exports.LengthLimiter = LengthLimiter;

View file

@ -0,0 +1,116 @@
/* eslint no-console: 0, new-cap: 0 */
'use strict';
let imapFormalSyntax = require('./imap-formal-syntax');
/**
* Compiles an input object into
*/
module.exports = function (response, asArray, isLogging) {
let respParts = [];
let resp = (response.tag || '') + (response.command ? ' ' + response.command : '');
let val;
let lastType;
let walk = function (node) {
if (lastType === 'LITERAL' || (['(', '<', '['].indexOf(resp.substr(-1)) < 0 && resp.length)) {
resp += ' ';
}
if (Array.isArray(node)) {
lastType = 'LIST';
resp += '(';
node.forEach(walk);
resp += ')';
return;
}
if (!node && typeof node !== 'string' && typeof node !== 'number') {
resp += 'NIL';
return;
}
if (typeof node === 'string') {
if (isLogging && node.length > 20) {
resp += '"(* ' + node.length + 'B string *)"';
} else {
resp += JSON.stringify(node);
}
return;
}
if (typeof node === 'number') {
resp += Math.round(node) || 0; // Only integers allowed
return;
}
lastType = node.type;
if (isLogging && node.sensitive) {
resp += '"(* value hidden *)"';
return;
}
switch (node.type.toUpperCase()) {
case 'LITERAL':
if (isLogging) {
resp += '"(* ' + node.value.length + 'B literal *)"';
} else {
if (!node.value) {
resp += '{0}\r\n';
} else {
resp += '{' + node.value.length + '}\r\n';
}
respParts.push(resp);
resp = node.value || '';
}
break;
case 'STRING':
if (isLogging && node.value.length > 20) {
resp += '"(* ' + node.value.length + 'B string *)"';
} else {
resp += JSON.stringify(node.value || '');
}
break;
case 'TEXT':
case 'SEQUENCE':
resp += node.value || '';
break;
case 'NUMBER':
resp += (node.value || 0);
break;
case 'ATOM':
case 'SECTION':
val = node.value || '';
if (imapFormalSyntax.verify(val.charAt(0) === '\\' ? val.substr(1) : val, imapFormalSyntax['ATOM-CHAR']()) >= 0) {
val = JSON.stringify(val);
}
resp += val;
if (node.section) {
resp += '[';
node.section.forEach(walk);
resp += ']';
}
if (node.partial) {
resp += '<' + node.partial.join('.') + '>';
}
break;
}
};
[].concat(response.attributes || []).forEach(walk);
if (resp.length) {
respParts.push(resp);
}
return asArray ? respParts : respParts.join('');
};

View file

@ -0,0 +1,149 @@
/* eslint object-shorthand:0, new-cap: 0, no-useless-concat: 0 */
'use strict';
// IMAP Formal Syntax
// http://tools.ietf.org/html/rfc3501#section-9
function expandRange(start, end) {
let chars = [];
for (let i = start; i <= end; i++) {
chars.push(i);
}
return String.fromCharCode(...chars);
}
function excludeChars(source, exclude) {
let sourceArr = Array.prototype.slice.call(source);
for (let i = sourceArr.length - 1; i >= 0; i--) {
if (exclude.indexOf(sourceArr[i]) >= 0) {
sourceArr.splice(i, 1);
}
}
return sourceArr.join('');
}
module.exports = {
CHAR: function () {
let value = expandRange(0x01, 0x7F);
this.CHAR = function () {
return value;
};
return value;
},
CHAR8: function () {
let value = expandRange(0x01, 0xFF);
this.CHAR8 = function () {
return value;
};
return value;
},
SP: function () {
return ' ';
},
CTL: function () {
let value = expandRange(0x00, 0x1F) + '\x7F';
this.CTL = function () {
return value;
};
return value;
},
DQUOTE: function () {
return '"';
},
ALPHA: function () {
let value = expandRange(0x41, 0x5A) + expandRange(0x61, 0x7A);
this.ALPHA = function () {
return value;
};
return value;
},
DIGIT: function () {
let value = expandRange(0x30, 0x39) + expandRange(0x61, 0x7A);
this.DIGIT = function () {
return value;
};
return value;
},
'ATOM-CHAR': function () {
let value = excludeChars(this.CHAR(), this['atom-specials']());
this['ATOM-CHAR'] = function () {
return value;
};
return value;
},
'ASTRING-CHAR': function () {
let value = this['ATOM-CHAR']() + this['resp-specials']();
this['ASTRING-CHAR'] = function () {
return value;
};
return value;
},
'TEXT-CHAR': function () {
let value = excludeChars(this.CHAR(), '\r\n');
this['TEXT-CHAR'] = function () {
return value;
};
return value;
},
'atom-specials': function () {
let value = '(' + ')' + '{' + this.SP() + this.CTL() + this['list-wildcards']() +
this['quoted-specials']() + this['resp-specials']();
this['atom-specials'] = function () {
return value;
};
return value;
},
'list-wildcards': function () {
return '%' + '*';
},
'quoted-specials': function () {
let value = this.DQUOTE() + '\\';
this['quoted-specials'] = function () {
return value;
};
return value;
},
'resp-specials': function () {
return ']';
},
tag: function () {
let value = excludeChars(this['ASTRING-CHAR'](), '+');
this.tag = function () {
return value;
};
return value;
},
command: function () {
let value = this.ALPHA() + this.DIGIT();
this.command = function () {
return value;
};
return value;
},
verify: function (str, allowedChars) {
for (let i = 0, len = str.length; i < len; i++) {
if (allowedChars.indexOf(str.charAt(i)) < 0) {
return i;
}
}
return -1;
}
};

View file

@ -0,0 +1,11 @@
'use strict';
let parser = require('./imap-parser');
let compiler = require('./imap-compiler');
let compileStream = require('./imap-compile-stream');
module.exports = {
parser,
compiler,
compileStream
};

View file

@ -0,0 +1,636 @@
/* eslint new-cap: 0 */
'use strict';
let imapFormalSyntax = require('./imap-formal-syntax');
class TokenParser {
constructor(parent, startPos, str, options) {
this.str = (str || '').toString();
this.options = options || {};
this.parent = parent;
this.tree = this.currentNode = this.createNode();
this.pos = startPos || 0;
this.currentNode.type = 'TREE';
this.state = 'NORMAL';
this.processString();
}
getAttributes() {
let attributes = [],
branch = attributes;
let walk = function (node) {
let curBranch = branch;
let elm;
let partial;
if (!node.closed && node.type === 'SEQUENCE' && node.value === '*') {
node.closed = true;
node.type = 'ATOM';
}
// If the node was never closed, throw it
if (!node.closed) {
throw new Error('Unexpected end of input at position ' + (this.pos + this.str.length - 1));
}
let type = (node.type || '').toString().toUpperCase();
switch (type) {
case 'LITERAL':
case 'STRING':
case 'SEQUENCE':
elm = {
type: node.type.toUpperCase(),
value: node.value
};
branch.push(elm);
break;
case 'ATOM':
if (node.value.toUpperCase() === 'NIL') {
branch.push(null);
break;
}
elm = {
type: node.type.toUpperCase(),
value: node.value
};
branch.push(elm);
break;
case 'SECTION':
branch = branch[branch.length - 1].section = [];
break;
case 'LIST':
elm = [];
branch.push(elm);
branch = elm;
break;
case 'PARTIAL':
partial = node.value.split('.').map(Number);
branch[branch.length - 1].partial = partial;
break;
}
node.childNodes.forEach(childNode => walk(childNode));
branch = curBranch;
}.bind(this);
walk(this.tree);
return attributes;
}
createNode(parentNode, startPos) {
let node = {
childNodes: [],
type: false,
value: '',
closed: true
};
if (parentNode) {
node.parentNode = parentNode;
}
if (typeof startPos === 'number') {
node.startPos = startPos;
}
if (parentNode) {
parentNode.childNodes.push(node);
}
return node;
}
processString() {
let chr, i, len,
checkSP = function () {
// jump to the next non whitespace pos
while (this.str.charAt(i + 1) === ' ') {
i++;
}
}.bind(this);
for (i = 0, len = this.str.length; i < len; i++) {
chr = this.str.charAt(i);
switch (this.state) {
case 'NORMAL':
switch (chr) {
// DQUOTE starts a new string
case '"':
this.currentNode = this.createNode(this.currentNode, this.pos + i);
this.currentNode.type = 'string';
this.state = 'STRING';
this.currentNode.closed = false;
break;
// ( starts a new list
case '(':
this.currentNode = this.createNode(this.currentNode, this.pos + i);
this.currentNode.type = 'LIST';
this.currentNode.closed = false;
break;
// ) closes a list
case ')':
if (this.currentNode.type !== 'LIST') {
throw new Error('Unexpected list terminator ) at position ' + (this.pos + i));
}
this.currentNode.closed = true;
this.currentNode.endPos = this.pos + i;
this.currentNode = this.currentNode.parentNode;
checkSP();
break;
// ] closes section group
case ']':
if (this.currentNode.type !== 'SECTION') {
throw new Error('Unexpected section terminator ] at position ' + (this.pos + i));
}
this.currentNode.closed = true;
this.currentNode.endPos = this.pos + i;
this.currentNode = this.currentNode.parentNode;
checkSP();
break;
// < starts a new partial
case '<':
if (this.str.charAt(i - 1) !== ']') {
this.currentNode = this.createNode(this.currentNode, this.pos + i);
this.currentNode.type = 'ATOM';
this.currentNode.value = chr;
this.state = 'ATOM';
} else {
this.currentNode = this.createNode(this.currentNode, this.pos + i);
this.currentNode.type = 'PARTIAL';
this.state = 'PARTIAL';
this.currentNode.closed = false;
}
break;
// { starts a new literal
case '{':
this.currentNode = this.createNode(this.currentNode, this.pos + i);
this.currentNode.type = 'LITERAL';
this.state = 'LITERAL';
this.currentNode.closed = false;
break;
// ( starts a new sequence
case '*':
this.currentNode = this.createNode(this.currentNode, this.pos + i);
this.currentNode.type = 'SEQUENCE';
this.currentNode.value = chr;
this.currentNode.closed = false;
this.state = 'SEQUENCE';
break;
// normally a space should never occur
case ' ':
// just ignore
break;
// [ starts section
case '[':
// If it is the *first* element after response command, then process as a response argument list
if (['OK', 'NO', 'BAD', 'BYE', 'PREAUTH'].indexOf(this.parent.command.toUpperCase()) >= 0 && this.currentNode === this.tree) {
this.currentNode.endPos = this.pos + i;
this.currentNode = this.createNode(this.currentNode, this.pos + i);
this.currentNode.type = 'ATOM';
this.currentNode = this.createNode(this.currentNode, this.pos + i);
this.currentNode.type = 'SECTION';
this.currentNode.closed = false;
this.state = 'NORMAL';
// RFC2221 defines a response code REFERRAL whose payload is an
// RFC2192/RFC5092 imapurl that we will try to parse as an ATOM but
// fail quite badly at parsing. Since the imapurl is such a unique
// (and crazy) term, we just specialize that case here.
if (this.str.substr(i + 1, 9).toUpperCase() === 'REFERRAL ') {
// create the REFERRAL atom
this.currentNode = this.createNode(this.currentNode, this.pos + i + 1);
this.currentNode.type = 'ATOM';
this.currentNode.endPos = this.pos + i + 8;
this.currentNode.value = 'REFERRAL';
this.currentNode = this.currentNode.parentNode;
// eat all the way through the ] to be the IMAPURL token.
this.currentNode = this.createNode(this.currentNode, this.pos + i + 10);
// just call this an ATOM, even though IMAPURL might be more correct
this.currentNode.type = 'ATOM';
// jump i to the ']'
i = this.str.indexOf(']', i + 10);
this.currentNode.endPos = this.pos + i - 1;
this.currentNode.value = this.str.substring(this.currentNode.startPos - this.pos,
this.currentNode.endPos - this.pos + 1);
this.currentNode = this.currentNode.parentNode;
// close out the SECTION
this.currentNode.closed = true;
this.currentNode = this.currentNode.parentNode;
checkSP();
}
break;
}
/* falls through */
default:
// Any ATOM supported char starts a new Atom sequence, otherwise throw an error
// Allow \ as the first char for atom to support system flags
// Allow % to support LIST '' %
if (imapFormalSyntax['ATOM-CHAR']().indexOf(chr) < 0 && chr !== '\\' && chr !== '%') {
throw new Error('Unexpected char at position ' + (this.pos + i));
}
this.currentNode = this.createNode(this.currentNode, this.pos + i);
this.currentNode.type = 'ATOM';
this.currentNode.value = chr;
this.state = 'ATOM';
break;
}
break;
case 'ATOM':
// space finishes an atom
if (chr === ' ') {
this.currentNode.endPos = this.pos + i - 1;
this.currentNode = this.currentNode.parentNode;
this.state = 'NORMAL';
break;
}
//
if (
this.currentNode.parentNode &&
(
(chr === ')' && this.currentNode.parentNode.type === 'LIST') ||
(chr === ']' && this.currentNode.parentNode.type === 'SECTION')
)
) {
this.currentNode.endPos = this.pos + i - 1;
this.currentNode = this.currentNode.parentNode;
this.currentNode.closed = true;
this.currentNode.endPos = this.pos + i;
this.currentNode = this.currentNode.parentNode;
this.state = 'NORMAL';
checkSP();
break;
}
if ((chr === ',' || chr === ':') && this.currentNode.value.match(/^\d+$/)) {
this.currentNode.type = 'SEQUENCE';
this.currentNode.closed = true;
this.state = 'SEQUENCE';
}
// [ starts a section group for this element
if (chr === '[') {
// allowed only for selected elements
if (['BODY', 'BODY.PEEK'].indexOf(this.currentNode.value.toUpperCase()) < 0) {
throw new Error('Unexpected section start char [ at position ' + this.pos);
}
this.currentNode.endPos = this.pos + i;
this.currentNode = this.createNode(this.currentNode.parentNode, this.pos + i);
this.currentNode.type = 'SECTION';
this.currentNode.closed = false;
this.state = 'NORMAL';
break;
}
if (chr === '<') {
throw new Error('Unexpected start of partial at position ' + this.pos);
}
// if the char is not ATOM compatible, throw. Allow \* as an exception
if (imapFormalSyntax['ATOM-CHAR']().indexOf(chr) < 0 && chr !== ']' && !(chr === '*' && this.currentNode.value === '\\')) {
throw new Error('Unexpected char at position ' + (this.pos + i));
} else if (this.currentNode.value === '\\*') {
throw new Error('Unexpected char at position ' + (this.pos + i));
}
this.currentNode.value += chr;
break;
case 'STRING':
// DQUOTE ends the string sequence
if (chr === '"') {
this.currentNode.endPos = this.pos + i;
this.currentNode.closed = true;
this.currentNode = this.currentNode.parentNode;
this.state = 'NORMAL';
checkSP();
break;
}
// \ Escapes the following char
if (chr === '\\') {
i++;
if (i >= len) {
throw new Error('Unexpected end of input at position ' + (this.pos + i));
}
chr = this.str.charAt(i);
}
/* // skip this check, otherwise the parser might explode on binary input
if (imapFormalSyntax['TEXT-CHAR']().indexOf(chr) < 0) {
throw new Error('Unexpected char at position ' + (this.pos + i));
}
*/
this.currentNode.value += chr;
break;
case 'PARTIAL':
if (chr === '>') {
if (this.currentNode.value.substr(-1) === '.') {
throw new Error('Unexpected end of partial at position ' + this.pos);
}
this.currentNode.endPos = this.pos + i;
this.currentNode.closed = true;
this.currentNode = this.currentNode.parentNode;
this.state = 'NORMAL';
checkSP();
break;
}
if (chr === '.' && (!this.currentNode.value.length || this.currentNode.value.match(/\./))) {
throw new Error('Unexpected partial separator . at position ' + this.pos);
}
if (imapFormalSyntax.DIGIT().indexOf(chr) < 0 && chr !== '.') {
throw new Error('Unexpected char at position ' + (this.pos + i));
}
if (this.currentNode.value.match(/^0$|\.0$/) && chr !== '.') {
throw new Error('Invalid partial at position ' + (this.pos + i));
}
this.currentNode.value += chr;
break;
case 'LITERAL':
if (this.currentNode.started) {
//if(imapFormalSyntax['CHAR8']().indexOf(chr) < 0){
if (chr === '\u0000') {
throw new Error('Unexpected \\x00 at position ' + (this.pos + i));
}
this.currentNode.value += chr;
if (this.currentNode.value.length >= this.currentNode.literalLength) {
this.currentNode.endPos = this.pos + i;
this.currentNode.closed = true;
this.currentNode = this.currentNode.parentNode;
this.state = 'NORMAL';
checkSP();
}
break;
}
if (chr === '+' && this.options.literalPlus) {
this.currentNode.literalPlus = true;
break;
}
if (chr === '}') {
if (!('literalLength' in this.currentNode)) {
throw new Error('Unexpected literal prefix end char } at position ' + (this.pos + i));
}
if (this.str.charAt(i + 1) === '\n') {
i++;
} else if (this.str.charAt(i + 1) === '\r' && this.str.charAt(i + 2) === '\n') {
i += 2;
} else {
throw new Error('Unexpected char at position ' + (this.pos + i));
}
this.currentNode.literalLength = Number(this.currentNode.literalLength);
this.currentNode.started = true;
if (!this.currentNode.literalLength) {
// special case where literal content length is 0
// close the node right away, do not wait for additional input
this.currentNode.endPos = this.pos + i;
this.currentNode.closed = true;
this.currentNode = this.currentNode.parentNode;
this.state = 'NORMAL';
checkSP();
}
break;
}
if (imapFormalSyntax.DIGIT().indexOf(chr) < 0) {
throw new Error('Unexpected char at position ' + (this.pos + i));
}
if (this.currentNode.literalLength === '0') {
throw new Error('Invalid literal at position ' + (this.pos + i));
}
this.currentNode.literalLength = (this.currentNode.literalLength || '') + chr;
break;
case 'SEQUENCE':
// space finishes the sequence set
if (chr === ' ') {
if (!this.currentNode.value.substr(-1).match(/\d/) && this.currentNode.value.substr(-1) !== '*') {
throw new Error('Unexpected whitespace at position ' + (this.pos + i));
}
if (this.currentNode.value.substr(-1) === '*' && this.currentNode.value.substr(-2, 1) !== ':') {
throw new Error('Unexpected whitespace at position ' + (this.pos + i));
}
this.currentNode.closed = true;
this.currentNode.endPos = this.pos + i - 1;
this.currentNode = this.currentNode.parentNode;
this.state = 'NORMAL';
break;
} else if (this.currentNode.parentNode &&
chr === ']' &&
this.currentNode.parentNode.type === 'SECTION') {
this.currentNode.endPos = this.pos + i - 1;
this.currentNode = this.currentNode.parentNode;
this.currentNode.closed = true;
this.currentNode.endPos = this.pos + i;
this.currentNode = this.currentNode.parentNode;
this.state = 'NORMAL';
checkSP();
break;
}
if (chr === ':') {
if (!this.currentNode.value.substr(-1).match(/\d/) && this.currentNode.value.substr(-1) !== '*') {
throw new Error('Unexpected range separator : at position ' + (this.pos + i));
}
} else if (chr === '*') {
if ([',', ':'].indexOf(this.currentNode.value.substr(-1)) < 0) {
throw new Error('Unexpected range wildcard at position ' + (this.pos + i));
}
} else if (chr === ',') {
if (!this.currentNode.value.substr(-1).match(/\d/) && this.currentNode.value.substr(-1) !== '*') {
throw new Error('Unexpected sequence separator , at position ' + (this.pos + i));
}
if (this.currentNode.value.substr(-1) === '*' && this.currentNode.value.substr(-2, 1) !== ':') {
throw new Error('Unexpected sequence separator , at position ' + (this.pos + i));
}
} else if (!chr.match(/\d/)) {
throw new Error('Unexpected char at position ' + (this.pos + i));
}
if (chr.match(/\d/) && this.currentNode.value.substr(-1) === '*') {
throw new Error('Unexpected number at position ' + (this.pos + i));
}
this.currentNode.value += chr;
break;
}
}
}
}
class ParserInstance {
constructor(input, options) {
this.input = (input || '').toString();
this.options = options || {};
this.remainder = this.input;
this.pos = 0;
}
getTag() {
if (!this.tag) {
this.tag = this.getElement(imapFormalSyntax.tag() + '*+', true);
}
return this.tag;
}
getCommand() {
let responseCode;
if (!this.command) {
this.command = this.getElement(imapFormalSyntax.command());
}
switch ((this.command || '').toString().toUpperCase()) {
case 'OK':
case 'NO':
case 'BAD':
case 'PREAUTH':
case 'BYE':
responseCode = this.remainder.match(/^ \[(?:[^\]]*\])+/);
if (responseCode) {
this.humanReadable = this.remainder.substr(responseCode[0].length).trim();
this.remainder = responseCode[0];
} else {
this.humanReadable = this.remainder.trim();
this.remainder = '';
}
break;
}
return this.command;
}
getElement(syntax) {
let match, element, errPos;
if (this.remainder.match(/^\s/)) {
throw new Error('Unexpected whitespace at position ' + this.pos);
}
if ((match = this.remainder.match(/^[^\s]+(?=\s|$)/))) {
element = match[0];
if ((errPos = imapFormalSyntax.verify(element, syntax)) >= 0) {
throw new Error('Unexpected char at position ' + (this.pos + errPos));
}
} else {
throw new Error('Unexpected end of input at position ' + this.pos);
}
this.pos += match[0].length;
this.remainder = this.remainder.substr(match[0].length);
return element;
}
getSpace() {
if (!this.remainder.length) {
throw new Error('Unexpected end of input at position ' + this.pos);
}
if (imapFormalSyntax.verify(this.remainder.charAt(0), imapFormalSyntax.SP()) >= 0) {
throw new Error('Unexpected char at position ' + this.pos);
}
this.pos++;
this.remainder = this.remainder.substr(1);
}
getAttributes() {
if (!this.remainder.length) {
throw new Error('Unexpected end of input at position ' + this.pos);
}
if (this.remainder.match(/^\s/)) {
throw new Error('Unexpected whitespace at position ' + this.pos);
}
return new TokenParser(this, this.pos, this.remainder, this.options).getAttributes();
}
}
module.exports = function (command, options) {
let parser, response = {};
options = options || {};
parser = new ParserInstance(command, options);
response.tag = parser.getTag();
parser.getSpace();
response.command = parser.getCommand();
if (['UID', 'AUTHENTICATE'].indexOf((response.command || '').toUpperCase()) >= 0) {
parser.getSpace();
response.command += ' ' + parser.getElement(imapFormalSyntax.command());
}
if (parser.remainder.trim().length) {
parser.getSpace();
response.attributes = parser.getAttributes();
}
if (parser.humanReadable) {
response.attributes = (response.attributes || []).concat({
type: 'TEXT',
value: parser.humanReadable
});
}
return response;
};

View file

@ -0,0 +1,228 @@
'use strict';
let imapHandler = require('./handler/imap-handler');
const MAX_MESSAGE_SIZE = 1 * 1024 * 1024;
let commands = new Map([
/*eslint-disable global-require*/
// require must normally be on top of the module
['NOOP', require('./commands/noop')],
['CAPABILITY', require('./commands/capability')],
['LOGOUT', require('./commands/logout')],
['ID', require('./commands/id')],
['STARTTLS', require('./commands/starttls')],
['LOGIN', require('./commands/login')],
['AUTHENTICATE PLAIN', require('./commands/authenticate-plain')],
['NAMESPACE', require('./commands/namespace')],
['LIST', require('./commands/list')],
['LSUB', require('./commands/lsub')],
['SUBSCRIBE', require('./commands/subscribe')],
['UNSUBSCRIBE', require('./commands/unsubscribe')],
['CREATE', require('./commands/create')],
['DELETE', require('./commands/delete')],
['RENAME', require('./commands/rename')],
['SELECT', require('./commands/select')],
['EXAMINE', require('./commands/select')],
['IDLE', require('./commands/idle')],
['CHECK', require('./commands/check')],
['STATUS', require('./commands/status')],
['APPEND', require('./commands/append')],
['STORE', require('./commands/store')],
['UID STORE', require('./commands/uid-store')],
['EXPUNGE', require('./commands/expunge')],
['UID EXPUNGE', require('./commands/uid-expunge')],
['CLOSE', require('./commands/close')],
['UNSELECT', require('./commands/unselect')],
['COPY', require('./commands/copy')],
['UID COPY', require('./commands/copy')],
['FETCH', require('./commands/fetch')],
['UID FETCH', require('./commands/fetch')],
['SEARCH', require('./commands/search')],
['UID SEARCH', require('./commands/search')],
['ENABLE', require('./commands/enable')]
/*eslint-enable global-require*/
]);
class IMAPCommand {
constructor(connection) {
this.connection = connection;
this.payload = '';
this.first = true;
}
append(command, callback) {
let chunks = [];
let chunklen = 0;
this.payload += command.value;
if (this.first) {
// fetch tag and command name
this.first = false;
// only check payload if it is a regular command, not input for something else
if (typeof this.connection._nextHandler !== 'function') {
let match = /^([^\s]+)(?:\s+((?:AUTHENTICATE |UID )?[^\s]+)|$)/i.exec(command.value) || [];
this.tag = match[1];
this.command = (match[2] || '').trim().toUpperCase();
if (!this.command || !this.tag) {
this.connection.send('* BAD Invalid tag');
return callback(new Error('Invalid tag'));
}
if (!commands.has(this.command)) {
this.connection.send(this.tag + ' BAD Unknown command: ' + this.command);
return callback(new Error('Unknown command'));
}
}
}
if (command.literal) {
if (
// Allow large literals for selected commands only
(['APPEND'].indexOf(this.command) < 0 && command.expecting > 1024) ||
// Deny all literals bigger than maxMessage
command.expecting > Math.max(Number(this.connection._server.options.maxMessage) || 0, MAX_MESSAGE_SIZE)) {
this.connection._server.logger.debug('[%s] C:', this.connection.id, this.payload);
this.connection.send(this.tag + ' NO Literal too big');
return callback(new Error('Literal too big'));
}
// Accept literal input
this.connection.send('+ Go ahead');
// currently the stream is buffered into a large string and thats it.
// in the future we might consider some kind of actual stream usage
command.literal.on('data', chunk => {
chunks.push(chunk);
chunklen += chunk.length;
});
command.literal.on('end', () => {
this.payload += '\r\n' + Buffer.concat(chunks, chunklen).toString('binary');
command.readyCallback(); // call this once stream is fully processed and ready to accept next data
});
}
callback();
}
end(command, callback) {
let callbackSent = false;
let next = err => {
if (!callbackSent) {
callbackSent = true;
return callback(err);
}
};
this.append(command, err => {
if (err) {
return next(err);
}
this.connection._server.logger.debug('[%s] C:', this.connection.id, this.payload);
// check if the payload needs to be directod to a preset handler
if (typeof this.connection._nextHandler === 'function') {
return this.connection._nextHandler(this.payload, next);
}
try {
this.parsed = imapHandler.parser(this.payload);
} catch (E) {
this.connection.send(this.tag + ' BAD ' + E.message);
return next();
}
let handler = commands.get(this.command);
this.validateCommand(this.parsed, handler, err => {
if (err) {
this.connection.send(this.tag + ' ' + (err.response || 'BAD') + ' ' + err.message);
return next(err);
}
if (typeof handler.handler === 'function') {
handler.handler.call(this.connection, this.parsed, (err, response) => {
if (err) {
this.connection.send(this.tag + ' ' + (err.response || 'BAD') + ' ' + err.message);
return next(err);
}
// send EXPUNGE, EXISTS etc queued notices
this.sendNotifications(handler, () => {
// send command ready response
this.connection.writeStream.write({
tag: this.tag,
command: response.response,
attributes: [].concat(response.code ? {
type: 'SECTION',
section: [{
type: 'TEXT',
value: response.code
}]
} : []).concat({
type: 'TEXT',
value: response.message || this.command + ' completed'
})
});
next();
});
}, next);
} else {
this.connection.send(this.tag + ' NO Not implemented: ' + this.command);
return next();
}
});
});
}
sendNotifications(handler, callback) {
if (this.connection.state !== 'Selected' || !!handler.disableNotifications) {
// nothing to advertise if not in Selected state
return callback();
}
this.connection.emitNotifications();
return callback();
}
validateCommand(parsed, handler, callback) {
let schema = handler.schema || [];
let maxArgs = schema.length;
let minArgs = schema.filter(item => !item.optional).length;
// Check if the command can be run in current state
if (handler.state && [].concat(handler.state || []).indexOf(this.connection.state) < 0) {
return callback(new Error(parsed.command.toUpperCase() + ' not allowed now'));
}
if (handler.schema === false) {
//schema check is disabled
return callback();
}
// Deny commands with too many arguments
if (parsed.attributes && parsed.attributes.length > maxArgs) {
return callback(new Error('Too many arguments provided'));
}
// Deny commands with too little arguments
if ((parsed.attributes && parsed.attributes.length || 0) < minArgs) {
return callback(new Error('Not enough arguments provided'));
}
callback();
}
}
module.exports.IMAPCommand = IMAPCommand;

View file

@ -0,0 +1,49 @@
'use strict';
let imapHandler = require('./handler/imap-handler');
let Transform = require('stream').Transform;
class IMAPComposer extends Transform {
constructor(options) {
super();
Transform.call(this, {
writableObjectMode: true
});
this.connection = options.connection;
}
_transform(obj, encoding, done) {
if (!obj) {
return done();
}
if (typeof obj.pipe === 'function') {
// pipe stream to socket and wait until it finishes before continuing
this.connection._server.logger.debug('[%s] S: <pipe message stream to socket>', this.connection.id);
obj.pipe(this.connection._socket, {
end: false
});
obj.on('error', err => this.emit('error', err));
obj.on('end', () => {
this.push('\r\n');
done();
});
return;
}
let compiled = imapHandler.compiler(obj);
this.connection._server.logger.debug('[%s] S:', this.connection.id, compiled);
this.push(new Buffer(compiled + '\r\n', 'binary'));
done();
}
_flush(done) {
done();
}
}
module.exports.IMAPComposer = IMAPComposer;

View file

@ -0,0 +1,622 @@
'use strict';
let IMAPStream = require('./imap-stream').IMAPStream;
let IMAPCommand = require('./imap-command').IMAPCommand;
let IMAPComposer = require('./imap-composer').IMAPComposer;
let imapTools = require('./imap-tools');
let search = require('./search');
let dns = require('dns');
let crypto = require('crypto');
let os = require('os');
let EventEmitter = require('events').EventEmitter;
let packageInfo = require('../../package');
const SOCKET_TIMEOUT = 30 * 60 * 1000;
/**
* Creates a handler for new socket
*
* @constructor
* @param {Object} server Server instance
* @param {Object} socket Socket instance
*/
class IMAPConnection extends EventEmitter {
constructor(server, socket) {
super();
// Random session ID, used for logging
this.id = crypto.randomBytes(9).toString('base64');
this._server = server;
this._socket = socket;
this.writeStream = new IMAPComposer({
connection: this
});
this.writeStream.pipe(this._socket);
this.writeStream.on('error', this._onError.bind(this));
// session data (envelope, user etc.)
this.session = false;
// If true then the connection is currently being upgraded to TLS
this._upgrading = false;
// Parser instance for the incoming stream
this._parser = new IMAPStream();
// Set handler for incoming commands
this._parser.oncommand = this._onCommand.bind(this);
// Manage multi part command
this._currentCommand = false;
// If set, then data payload is not executed as a command but as an argument for this function
this._nextHandler = false;
// If true, then the connection is using TLS
this.secure = !!this._server.options.secure;
// Store remote address for later usage
this.remoteAddress = this._socket.remoteAddress;
// Server hostname for the greegins
this.name = this._server.options.name || os.hostname();
this.state = 'Not Authenticated';
this._listenerData = false;
// selected mailbox metadata
this.selected = false;
// ignore timeouts if true
this.idling = false;
// indicates if CONDSTORE is enabled for the session
this.condstoreEnabled = false;
// Resolved hostname for remote IP address
this.clientHostname = false;
// increment connection count
this._closing = false;
this._closed = false;
}
/**
* Initiates the connection. Checks connection limits and reverse resolves client hostname. The client
* is not allowed to send anything before init has finished otherwise 'You talk too soon' error is returned
*/
init() {
// Setup event handlers for the socket
this._setListeners();
// Resolve hostname for the remote IP
// we do not care for errors as we consider the ip as unresolved in this case, no big deal
dns.reverse(this.remoteAddress, (err, hostnames) => { // eslint-disable-line handle-callback-err
if (this._closing || this._closed) {
return;
}
this.clientHostname = hostnames && hostnames.shift() || '[' + this.remoteAddress + ']';
this._startSession();
this._server.logger.info('[%s] Connection from %s', this.id, this.clientHostname);
this.send('* OK ' + (this._server.options.id && this._server.options.id.name || packageInfo.name) + ' ready');
});
}
/**
* Send data to socket
*
* @param {Number} code Response code
* @param {String|Array} data If data is Array, send a multi-line response
*/
send(payload, callback) {
if (this._socket && this._socket.writable) {
this._socket.write(payload + '\r\n', 'binary', callback);
this._server.logger.debug('[%s] S:', this.id, payload);
}
}
/**
* Close socket
*/
close() {
if (!this._socket.destroyed && this._socket.writable) {
this._socket.end();
}
this._server.connections.delete(this);
this._closing = true;
}
// PRIVATE METHODS
/**
* Setup socket event handlers
*/
_setListeners() {
this._socket.on('close', this._onClose.bind(this));
this._socket.on('end', this._onEnd.bind(this));
this._socket.on('error', this._onError.bind(this));
this._socket.setTimeout(this._server.options.socketTimeout || SOCKET_TIMEOUT, this._onTimeout.bind(this));
this._socket.pipe(this._parser);
}
/**
* Fired when the socket is closed
* @event
*/
_onEnd() {
this._server.logger.info('[%s] Connection END', this.id);
if (!this._closed) {
this._onClose();
}
}
/**
* Fired when the socket is closed
* @event
*/
_onClose( /* hadError */ ) {
if (this._closed) {
return;
}
this._parser = false;
this.state = 'Closed';
if (this._dataStream) {
this._dataStream.unpipe();
this._dataStream = null;
}
if (this._listenerData) {
this._listenerData.clear();
}
this._server.connections.delete(this);
if (this._closed) {
return;
}
this._closed = true;
this._closing = false;
this._server.logger.info('[%s] Connection closed to %s', this.id, this.clientHostname);
}
/**
* Fired when an error occurs with the socket
*
* @event
* @param {Error} err Error object
*/
_onError(err) {
if (err.code === 'ECONNRESET' || err.code === 'EPIPE') {
this.close(); // mark connection as 'closing'
return;
}
this._server.logger.error('[%s] %s', this.id, err.message);
this.emit('error', err);
}
/**
* Fired when socket timeouts. Closes connection
*
* @event
*/
_onTimeout() {
this._server.logger.info('[%s] Connection TIMEOUT', this.id);
if (this.idling) {
return; // ignore timeouts when IDLEing
}
this.send('* BYE Idle timeout, closing connection');
this.close();
}
/**
* Checks if a selected command is available and ivokes it
*
* @param {Buffer} command Single line of data from the client
* @param {Function} callback Callback to run once the command is processed
*/
_onCommand(command, callback) {
let currentCommand = this._currentCommand;
callback = callback || (() => false);
if (this._upgrading) {
// ignore any commands before TLS upgrade is finished
return callback();
}
if (!currentCommand) {
this._currentCommand = currentCommand = new IMAPCommand(this);
}
if (!command.final) {
currentCommand.append(command, callback);
} else {
this._currentCommand = false;
currentCommand.end(command, callback);
}
}
/**
* Sets up a new session
*/
_startSession() {
this.session = {
id: this.id,
selected: this.selected,
remoteAddress: this.remoteAddress,
clientHostname: this.clientHostname,
writeStream: this.writeStream,
socket: this._socket,
formatResponse: this.formatResponse.bind(this),
getQueryResponse: imapTools.getQueryResponse,
matchSearchQuery: search.matchSearchQuery
};
}
/**
* Sets up notification listener from upstream
*
* @param {Function} done Called once listeners are updated
*/
updateNotificationListener(done) {
if (this._listenerData) {
if (!this.selected || this._listenerData.mailbox !== this.selected.mailbox) {
// registered against some mailbox, unregister from it
this._listenerData.clear();
} else if (this._listenerData.mailbox === this.selected.mailbox) {
// already registered
return done();
}
}
if (!this.selected) {
this._listenerData = false;
return done();
}
let cleared = false;
let listenerData = this._listenerData = {
mailbox: this.selected.mailbox,
lock: false,
clear: () => {
this._server.notifier.removeListener(this.session, listenerData.mailbox, listenerData.callback);
if (listenerData === this._listenerData) {
this._listenerData = false;
}
listenerData = false;
cleared = true;
},
callback: message => {
if (message) {
if (this.selected && message.action === 'DELETE' && message.mailbox === this.selected.mailbox) {
this.send('* BYE Selected mailbox was deleted, have to disconnect');
this.close();
return;
}
}
if (listenerData.lock) {
// race condition, do not allow fetching data before previous fetch is finished
return;
}
if (cleared) {
// some kind of a race condition, just ignore
return;
}
// if not selected anymore, remove itself
if (this.state !== 'Selected' || !this.selected) {
listenerData.clear();
return;
}
listenerData.lock = true;
this._server.notifier.getUpdates(this.session, this._listenerData.mailbox, this.selected.modifyIndex, (err, updates) => {
if (cleared) {
// client probably switched mailboxes while processing, just ignore all results
return;
}
listenerData.lock = false;
if (err) {
this._server.logger.info('[%s] Notification Error: %s', this.id, err.message);
return;
}
// if not selected anymore, remove itself
if (this.state !== 'Selected' || !this.selected) {
listenerData.clear();
return;
}
if (!updates || !updates.length) {
return;
}
// store new incremental modify index
if (updates[updates.length - 1].modseq > this.selected.modifyIndex) {
this.selected.modifyIndex = updates[updates.length - 1].modseq;
}
// append received notifications to the list
this.selected.notifications = this.selected.notifications.concat(updates);
if (this.idling) {
// when idling emit notifications immediatelly
this.emitNotifications();
}
});
}
};
this._server.notifier.addListener(this.session, this._listenerData.mailbox, this._listenerData.callback);
return done();
}
// send notifications to client
emitNotifications() {
if (this.state !== 'Selected' || !this.selected || !this.selected.notifications.length) {
return;
}
let changed = false;
let existsResponse;
// show notifications
this._server.logger.info('[%s] Pending notifications: %s', this.id, this.selected.notifications.length);
// find UIDs that are both added and removed
let added = new Set(); // added UIDs
let removed = new Set(); // removed UIDs
let skip = new Set(); // UIDs that are removed before ever seen
for (let i = 0, len = this.selected.notifications.length; i < len; i++) {
let update = this.selected.notifications[i];
if (update.command === 'EXISTS') {
added.add(update.uid);
} else if (update.command === 'EXPUNGE') {
removed.add(update.uid);
}
}
removed.forEach(uid => {
if (added.has(uid)) {
skip.add(uid);
}
});
// filter multiple FETCH calls, only keep latest, otherwise might mess up MODSEQ responses
let fetches = new Set();
for (let i = this.selected.notifications.length - 1; i >= 0; i--) {
let update = this.selected.notifications[i];
if (update.command === 'FETCH') {
// skip multiple flag updates and updates for removed or newly added messages
if (fetches.has(update.uid) || added.has(update.uid) || removed.has(update.uid)) {
this.selected.notifications.splice(i, 1);
} else {
fetches.add(update.uid);
}
}
}
for (let i = 0, len = this.selected.notifications.length; i < len; i++) {
let update = this.selected.notifications[i];
// skip unnecessary entries that are already removed
if (skip.has(update.uid)) {
continue;
}
if (update.modseq > this.selected.modifyIndex) {
this.selected.modifyIndex = update.modseq;
}
this._server.logger.info('[%s] Processing notification: %s', this.id, JSON.stringify(update));
if (update.ignore === this.id) {
continue; // skip this
}
this._server.logger.info('[%s] UIDS: %s', this.id, JSON.stringify(this.selected.uidList));
switch (update.command) {
case 'EXISTS':
// Generate the response but do not send it yet (EXIST response generation is needed to modify the UID list)
// This way we can accumulate consecutive EXISTS responses into single one as
// only the last one actually matters to the client
existsResponse = this.formatResponse('EXISTS', update.uid);
changed = false;
break;
case 'EXPUNGE':
{
let seq = (this.selected.uidList || []).indexOf(update.uid);
this._server.logger.info('[%s] EXPUNGE %s', this.id, seq);
if (seq >= 0) {
let output = this.formatResponse('EXPUNGE', update.uid);
this.writeStream.write(output);
changed = true; // if no more EXISTS after this, then generate an additional EXISTS
}
break;
}
case 'FETCH':
this.writeStream.write(this.formatResponse('FETCH', update.uid, {
flags: update.flags,
modseq: this.selected.condstoreEnabled && update.modseq || false
}));
break;
}
}
if (existsResponse) {
// send cached EXISTS response
this.writeStream.write(existsResponse);
existsResponse = false;
}
if (changed) {
this.writeStream.write({
tag: '*',
command: String(this.selected.uidList.length),
attributes: [{
type: 'atom',
value: 'EXISTS'
}]
});
}
// clear queue
this.selected.notifications = [];
if (typeof this._server.onNotifications === 'function') {
setImmediate(this._server.onNotifications.bind(this._server, this.selected.mailbox, this.selected.modifyIndex, this.session));
}
}
formatResponse(command, uid, data) {
command = command.toUpperCase();
let seq;
if (command === 'EXISTS') {
this.selected.uidList.push(uid);
seq = this.selected.uidList.length;
} else {
seq = (this.selected.uidList || []).indexOf(uid);
if (seq < 0) {
return false;
}
seq++;
}
if (command === 'EXPUNGE') {
this.selected.uidList.splice(seq - 1, 1);
}
let response = {
tag: '*',
command: String(seq),
attributes: [{
type: 'atom',
value: command
}]
};
if (data) {
response.attributes.push([]);
if ('query' in data) {
// Response for FETCH command
data.query.forEach((item, i) => {
response.attributes[1].push(item.original);
if (['flags', 'modseq'].indexOf(item.item) >= 0) {
response.attributes[1].
push([].concat(data.values[i] || []).map(value => ({
type: 'ATOM',
value: (value || value === 0 ? value : '').toString()
})));
} else if (Object.prototype.toString.call(data.values[i]) === '[object Date]') {
response.attributes[1].push({
type: 'ATOM',
value: imapTools.formatInternalDate(data.values[i])
});
} else if (Array.isArray(data.values[i])) {
response.attributes[1].push(data.values[i]);
} else if (item.isLiteral) {
if (data.values[i] && data.values[i].type === 'stream') {
response.attributes[1].push({
type: 'LITERAL',
value: data.values[i].value,
expectedLength: data.values[i].expectedLength,
startFrom: data.values[i].startFrom,
maxLength: data.values[i].maxLength
});
} else {
response.attributes[1].push({
type: 'LITERAL',
value: data.values[i]
});
}
} else if (data.values[i] === '') {
response.attributes[1].push(data.values[i]);
} else {
response.attributes[1].push({
type: 'ATOM',
value: (data.values[i]).toString()
});
}
});
} else {
// Notification response
Object.keys(data).forEach(key => {
let value = data[key];
key = key.toUpperCase();
if (!value) {
return;
}
switch (key) {
case 'FLAGS':
value = [].concat(value || []).map(flag => (flag && flag.value ? flag : {
type: 'ATOM',
value: flag
}));
break;
case 'UID':
value = value && value.value ? value : {
type: 'ATOM',
value: (value || '0').toString()
};
break;
case 'MODSEQ':
value = [].concat(value && value.value ? value : {
type: 'ATOM',
value: (value || '0').toString()
});
break;
}
response.attributes[1].push({
type: 'ATOM',
value: key
});
response.attributes[1].push(value);
});
}
}
return response;
}
}
// Expose to the world
module.exports.IMAPConnection = IMAPConnection;

View file

@ -0,0 +1,184 @@
'use strict';
let net = require('net');
let tls = require('tls');
let IMAPConnection = require('./imap-connection').IMAPConnection;
let tlsOptions = require('./tls-options');
let EventEmitter = require('events').EventEmitter;
let util = require('util');
let clone = require('clone');
const CLOSE_TIMEOUT = 1 * 1000; // how much to wait until pending connections are terminated
/**
* Creates a IMAP server instance.
*
* @constructor
* @param {Object} options Connection and IMAP optionsž
*/
class IMAPServer extends EventEmitter {
constructor(options) {
super();
this.options = options ? clone(options) : {};
// apply TLS defaults if needed
if (this.options.secure) {
this.options = tlsOptions(this.options);
}
// setup logger
if ('logger' in this.options) {
// use provided logger or use vanity logger if option is set to false
this.logger = this.options.logger || {
info: () => false,
debug: () => false,
error: () => false
};
} else {
// create default console logger
this.logger = this._createDefaultLogger();
}
/**
* Timeout after close has been called until pending connections are forcibly closed
*/
this._closeTimeout = false;
/**
* A set of all currently open connections
*/
this.connections = new Set();
// setup server listener and connection handler
this.server = (this.options.secure ? tls : net).createServer(this.options, socket => {
let connection = new IMAPConnection(this, socket);
this.connections.add(connection);
connection.on('error', this._onError.bind(this));
connection.init();
});
this._setListeners();
}
/**
* Start listening on selected port and interface
*/
listen(...args) {
this.server.listen(...args);
}
/**
* Closes the server
*
* @param {Function} callback Callback to run once the server is fully closed
*/
close(callback) {
let connections = this.connections.size;
let timeout = this.options.closeTimeout || CLOSE_TIMEOUT;
// stop accepting new connections
this.server.close(() => {
clearTimeout(this._closeTimeout);
callback();
});
// close active connections
if (connections) {
this.logger.info('Server closing with %s pending connection%s, waiting %s seconds before terminating', connections, connections !== 1 ? 's' : '', timeout / 1000);
}
this._closeTimeout = setTimeout(() => {
connections = this.connections.size;
if (connections) {
this.logger.info('Closing %s pending connection%s to close the server', connections, connections !== 1 ? 's' : '');
this.connections.forEach(connection => {
connection.send('* BYE System shutdown');
connection.close();
});
}
}, timeout);
}
// PRIVATE METHODS
/**
* Generates a bunyan-like logger that prints to console
*
* @returns {Object} Bunyan logger instance
*/
_createDefaultLogger() {
let logger = {
_print: (...args) => {
let level = args.shift();
let message;
if (args.length > 1) {
message = util.format(...args);
} else {
message = args[0];
}
console.log('[%s] %s: %s', // eslint-disable-line no-console
new Date().toISOString().substr(0, 19).replace(/T/, ' '),
level.toUpperCase(),
message);
}
};
logger.info = logger._print.bind(null, 'info');
logger.debug = logger._print.bind(null, 'debug');
logger.error = logger._print.bind(null, 'error');
return logger;
}
/**
* Setup server event handlers
*/
_setListeners() {
this.server.on('listening', this._onListening.bind(this));
this.server.on('close', this._onClose.bind(this));
this.server.on('error', this._onError.bind(this));
}
/**
* Called when server started listening
*
* @event
*/
_onListening() {
let address = this.server.address();
this.logger.info(
'%sIMAP Server listening on %s:%s',
this.options.secure ? 'Secure ' : '',
address.family === 'IPv4' ? address.address : '[' + address.address + ']',
address.port);
}
/**
* Called when server is closed
*
* @event
*/
_onClose() {
this.logger.info('IMAP Server closed');
this.emit('close');
}
/**
* Called when an error occurs with the server
*
* @event
*/
_onError(err) {
this.emit('error', err);
}
}
// Expose to the world
module.exports.IMAPServer = IMAPServer;

171
imap-core/lib/imap-stream.js Executable file
View file

@ -0,0 +1,171 @@
'use strict';
let stream = require('stream');
let Writable = stream.Writable;
let PassThrough = stream.PassThrough;
/**
* Incoming IMAP stream parser. Detects and emits command payloads.
* If literal values are encountered the command payload is split into parts
* and all parts are emitted separately. The client must send the +\r\n or
* return a NO error for the literal
*
* @constructor
* @param {Object} [options] Optional Stream options object
*/
class IMAPStream extends Writable {
constructor(options) {
// init Writable
super();
this.options = options || {};
Writable.call(this, this.options);
// unprocessed chars from the last parsing iteration
this._remainder = '';
this._literal = false;
this._literalReady = false;
// how many literal bytes to wait for
this._expecting = 0;
// once the input stream ends, flush all output without expecting the newline
this.on('finish', this._flushData.bind(this));
}
/**
* Placeholder command handler. Override this with your own.
*/
oncommand( /* command, callback */ ) {
throw new Error('Command handler is not set');
}
// PRIVATE METHODS
/**
* Writable._write method.
*/
_write(chunk, encoding, done) {
if (!chunk || !chunk.length) {
return done();
}
let data = this._remainder + chunk.toString('binary');
this._remainder = '';
// start reading data
// regex is passed as an argument because we need to keep count of the lastIndex property
this._readValue(/\r?\n/g, data, 0, done);
}
/**
* Reads next command from incoming stream
*
* @param {RegExp} regex Regular expression object. Needed to keep lastIndex value
* @param {String} data Incoming data as binary string
* @param {Number} pos Cursor position in current data chunk
* @param {Function} done Function to call once data is processed
*/
_readValue(regex, data, pos, done) {
let match;
let line;
// Handle literal mode where we know how many bytes to expect before switching back to
// normal line based mode. All the data we receive is pumped to a passthrough stream
if (this._expecting > 0) {
if (data.length - pos <= 0) {
return done();
}
if (data.length - pos >= this._expecting) {
// all bytes received
this._literal.end(new Buffer(data.substr(pos, this._expecting), 'binary'));
pos += this._expecting;
this._expecting = 0;
this._literal = false;
if (this._literalReady) {
// can continue
this._literalReady = false;
} else {
this._literalReady = this._readValue.bind(this, /\r?\n/g, data.substr(pos), 0, done);
return;
}
} else {
// data still pending
this._literal.write(new Buffer(data.substr(pos), 'binary'), done);
this._expecting -= data.length - pos;
return; // wait for the next chunk
}
}
// search for the next newline
// exec keeps count of the last match with lastIndex
// so it knows from where to start with the next iteration
if ((match = regex.exec(data))) {
line = data.substr(pos, match.index - pos);
pos += line.length + match[0].length;
} else {
this._remainder = pos < data.length ? data.substr(pos) : '';
return done();
}
if ((match = /\{(\d+)\}$/.exec(line))) {
this._expecting = Number(match[1]);
if (!isNaN(match[1])) {
this._literal = new PassThrough();
this.oncommand({
value: line,
final: false,
expecting: this._expecting,
literal: this._literal,
// called once the stream has been processed
readyCallback: () => {
let next = this._literalReady;
if (typeof next === 'function') {
this._literalReady = false;
next();
} else {
this._literalReady = true;
}
}
}, err => {
if (err) {
this._expecting = 0;
this._literal = false;
this._literalReady = false;
}
setImmediate(this._readValue.bind(this, regex, data, pos, done));
});
return;
}
}
this.oncommand({
value: line,
final: true
}, this._readValue.bind(this, regex, data, pos, done));
}
/**
* Flushes remaining bytes
*/
_flushData() {
let line;
if (this._remainder) {
line = this._remainder;
this._remainder = '';
this.oncommand(new Buffer(line, 'binary'));
}
}
}
// Expose to the world
module.exports.IMAPStream = IMAPStream;

587
imap-core/lib/imap-tools.js Normal file
View file

@ -0,0 +1,587 @@
'use strict';
let Indexer = require('./indexer/indexer');
module.exports.systemFlags = ['\\answered', '\\flagged', '\\draft', '\\deleted', '\\seen'];
module.exports.fetchSchema = {
body: [true, {
type: /^(\d+\.)*(CONTENT|HEADER|HEADER\.FIELDS|HEADER\.FIELDS\.NOT|TEXT|MIME|\d+)$/i,
headers: /^(\d+\.)*(HEADER\.FIELDS|HEADER\.FIELDS\.NOT)$/i,
startFrom: 'optional',
maxLength: 'optional'
}],
bodystructure: true,
envelope: true,
flags: true,
internaldate: true,
rfc822: true,
'rfc822.header': true,
'rfc822.size': true,
'rfc822.text': true,
modseq: true,
uid: true
};
module.exports.searchSchema = {
charset: ['string'],
all: true,
answered: true,
bcc: ['string'],
before: ['date'],
body: ['string'],
cc: ['string'],
deleted: true,
draft: true,
flagged: true,
from: ['string'],
header: ['string', 'string'],
keyword: ['string'],
larger: ['number'],
modseq: [
['string', 'string', 'number'],
['number']
],
new: true,
not: ['expression'],
old: true,
on: ['date'],
or: ['expression', 'expression'],
recent: true,
seen: true,
sentbefore: ['date'],
senton: ['date'],
sentsince: ['date'],
since: ['date'],
smaller: ['number'],
subject: ['string'],
text: ['string'],
to: ['string'],
uid: ['sequence'],
unanswered: true,
undeleted: true,
undraft: true,
unflagged: true,
unkeyword: ['string'],
unseen: true
};
module.exports.searchMapping = {
all: {
key: 'all',
value: [true]
},
answered: {
key: 'flag',
value: ['\\Answered', true]
},
bcc: {
key: 'header',
value: ['bcc', '$1']
},
before: {
key: 'internaldate',
value: ['<', '$1']
},
cc: {
key: 'header',
value: ['cc', '$1']
},
deleted: {
key: 'flag',
value: ['\\Deleted', true]
},
draft: {
key: 'flag',
value: ['\\Draft', true]
},
flagged: {
key: 'flag',
value: ['\\Flagged', true]
},
from: {
key: 'header',
value: ['from', '$1']
},
keyword: {
key: 'flag',
value: ['$1', true]
},
larger: {
key: 'size',
value: ['>', '$1']
},
new: {
key: 'flag',
value: ['\\Recent', true, '\\Seen', false]
},
old: {
key: 'flag',
value: ['\\Recent', false]
},
on: {
key: 'internaldate',
value: ['=', '$1']
},
recent: {
key: 'flag',
value: ['\\Recent', true]
},
seen: {
key: 'flag',
value: ['\\Seen', true]
},
sentbefore: {
key: 'date',
value: ['<', '$1']
},
senton: {
key: 'date',
value: ['=', '$1']
},
sentsince: {
key: 'date',
value: ['>=', '$1']
},
since: {
key: 'internaldate',
value: ['>=', '$1']
},
smaller: {
key: 'size',
value: ['<', '$1']
},
subject: {
key: 'header',
value: ['subject', '$1']
},
to: {
key: 'header',
value: ['to', '$1']
},
unanswered: {
key: 'flag',
value: ['\\Answered', false]
},
undeleted: {
key: 'flag',
value: ['\\Deleted', false]
},
undraft: {
key: 'flag',
value: ['\\Draft', false]
},
unflagged: {
key: 'flag',
value: ['\\Flagged', false]
},
unkeyword: {
key: 'flag',
value: ['$1', false]
},
unseen: {
key: 'flag',
value: ['\\Seen', false]
}
};
/**
* Checks if a sequence range string is valid or not
*
* @param {range} range Sequence range, eg "1,2,3:7"
* @returns {Boolean} True if the string looks like a sequence range
*/
module.exports.validateSequnce = function (range) {
return !!(range.length && /^(\d+|\*)(:\d+|:\*)?(,(\d+|\*)(:\d+|:\*)?)*$/.test(range));
};
module.exports.normalizeMailbox = function (mailbox) {
if (!mailbox) {
return '';
}
// trim slashes
mailbox = mailbox.replace(/^\/|\/$/g, () => '');
// Normalize case insensitive INBOX to always use uppercase
let parts = mailbox.split('/');
if (parts[0].toUpperCase() === 'INBOX') {
parts[0] = 'INBOX';
}
mailbox = parts.join('/');
return mailbox;
};
module.exports.generateFolderListing = function (folders, skipHierarchy) {
let items = new Map();
let parents = [];
folders.forEach(folder => {
let item;
if (typeof folder === 'string') {
folder = {
path: folder
};
}
if (!folder || typeof folder !== 'object') {
return;
}
let path = module.exports.normalizeMailbox(folder.path);
let parent, parentPath;
if (!path) {
return;
}
parent = path.split('/');
parent.pop();
while (parent.length) {
parentPath = parent.join('/');
if (parent && parents.indexOf(parentPath) < 0) {
parents.push(parentPath);
}
parent.pop();
}
item = {
flags: [].concat(folder.flags || []),
path
};
if (typeof folder.specialUse === 'string' && folder.specialUse) {
item.specialUse = folder.specialUse;
}
items.set(path, item);
});
// ensure INBOX
if (!items.has('INBOX')) {
items.set('INBOX', {
path: 'INBOX',
flags: []
});
}
// Adds \HasChildren flag for parent folders
parents.forEach(path => {
if (!items.has(path) && !skipHierarchy) {
// add virtual hierarchy folders
items.set(path, {
flags: ['\\Noselect'],
path
});
}
let parent = items.get(path);
if (parent && parent.flags.indexOf('\\HasChildren') < 0) {
parent.flags.push('\\HasChildren');
}
});
// converts cache Map to a response array
let result = [];
items.forEach(folder => {
// Adds \HasNoChildren flag for leaf folders
if (folder.flags.indexOf('\\HasChildren') < 0 && folder.flags.indexOf('\\HasNoChildren') < 0) {
folder.flags.push('\\HasNoChildren');
}
result.push(folder);
});
// sorts folders
result.sort((a, b) => {
let aParts = a.path.split('/');
let bParts = b.path.split('/');
for (let i = 0; i < aParts.length; i++) {
if (!bParts[i]) {
return 1;
}
if (aParts[i] !== bParts[i]) {
// prefer INBOX when sorting
if (i === 0 && aParts[i] === 'INBOX') {
return -1;
} else if (i === 0 && bParts[i] === 'INBOX') {
return 1;
}
return aParts[i].localeCompare(bParts[i]);
}
}
return 0;
});
return result;
};
module.exports.filterFolders = function (folders, query) {
query = query
// remove excess * and %
.replace(/\*\*+/g, '*').replace(/%%+/g, '%')
// escape special characters
.replace(/([\\^$+?!.():=\[\]|,\-])/g, '\\$1')
// setup *
.replace(/[*]/g, '.*')
// setup %
.replace(/[%]/g, '[^\/]*');
let regex = new RegExp('^' + query + '$', '');
return folders.filter(folder => !!regex.test(folder.path));
};
module.exports.getMessageRange = function (uidList, range, isUid) {
range = (range || '').toString();
let result = [];
let rangeParts = range.split(',');
let uid, i, len;
let totalMessages = uidList.length;
let maxUid = 0;
let inRange = (nr, ranges, total) => {
let range, from, to;
for (let i = 0, len = ranges.length; i < len; i++) {
range = ranges[i];
to = range.split(':');
from = to.shift();
if (from === '*') {
from = total;
}
from = Number(from) || 1;
to = to.pop() || from;
to = Number(to === '*' && total || to) || from;
if (nr >= Math.min(from, to) && nr <= Math.max(from, to)) {
return true;
}
}
return false;
};
for (i = 0, len = uidList.length; i < len; i++) {
if (uidList[i] > maxUid) {
maxUid = uidList[i];
}
}
for (i = 0, len = uidList.length; i < len; i++) {
uid = uidList[i] || 1;
if (inRange(isUid ? uid : i + 1, rangeParts, isUid ? maxUid : totalMessages)) {
result.push(uidList[i]);
}
}
return result;
};
module.exports.packMessageRange = function (uidList) {
if (!Array.isArray(uidList)) {
uidList = [].concat(uidList || []);
}
if (!uidList.length) {
return '';
}
uidList.sort((a, b) => (a - b));
let last = uidList[uidList.length - 1];
let result = [
[last]
];
for (let i = uidList.length - 2; i >= 0; i--) {
if (uidList[i] === uidList[i + 1] - 1) {
result[0].unshift(uidList[i]);
continue;
}
result.unshift([uidList[i]]);
}
result = result.map(item => {
if (item.length === 1) {
return item[0];
}
return item.shift() + ':' + item.pop();
});
return result.join(',');
};
/**
* Returns a date in GMT timezone
*
* @param {Date} date Date object to parse
* @returns {String} Internaldate formatted date
*/
module.exports.formatInternalDate = function (date) {
let day = date.getUTCDate(),
month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
][date.getUTCMonth()],
year = date.getUTCFullYear(),
hour = date.getUTCHours(),
minute = date.getUTCMinutes(),
second = date.getUTCSeconds(),
tz = 0, //date.getTimezoneOffset(),
tzHours = Math.abs(Math.floor(tz / 60)),
tzMins = Math.abs(tz) - tzHours * 60;
return (day < 10 ? '0' : '') + day + '-' + month + '-' + year + ' ' +
(hour < 10 ? '0' : '') + hour + ':' + (minute < 10 ? '0' : '') +
minute + ':' + (second < 10 ? '0' : '') + second + ' ' +
(tz > 0 ? '-' : '+') + (tzHours < 10 ? '0' : '') + tzHours +
(tzMins < 10 ? '0' : '') + tzMins;
};
/**
* Converts query data and message into an array of query responses.
*
* Message object must have the following properties:
*
* * raw string (binary) or buffer with the rfc822 contents of the message
* * uid message UID
* * flags - an array with message flags
* * date - internaldate date object
*
* Additionally the message object *should* have the following properties (if not present then generated automatically):
*
* * mimeTree - message MIME tree object
* * envelope - message IMAP envelope object
* * bodystructure - message bodustructure object
* * bodystructureShort - bodyscructure for the BODY query
*
* @param {Array} query Query objects
* @param {Object} message Message object
* @param {Object} options Options for the indexer
* @returns {Array} Resolved responses
*/
module.exports.getQueryResponse = function (query, message, options) {
// for optimization purposes try to use cached mimeTree etc. if available
// If these values are missing then generate these when first time required
// So if the query is for (UID FLAGS) then mimeTree is never generated
let mimeTree = message.mimeTree;
let indexer = new Indexer(options);
// generate response object
let values = [];
query.forEach(item => {
let value = '';
switch (item.item) {
case 'uid':
value = message.uid;
break;
case 'modseq':
value = message.modseq;
break;
case 'flags':
value = message.flags;
break;
case 'internaldate':
if (!message.internaldate) {
message.internaldate = new Date();
}
value = message.internaldate;
break;
case 'bodystructure':
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getBodyStructure(mimeTree);
break;
case 'envelope':
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getEnvelope(mimeTree);
break;
case 'rfc822':
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getContents(mimeTree);
break;
case 'rfc822.size':
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getSize(mimeTree);
break;
case 'rfc822.header':
// Equivalent to BODY[HEADER]
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = [].concat(mimeTree.header || []).join('\r\n') + '\r\n\r\n';
break;
case 'rfc822.text':
// Equivalent to BODY[TEXT]
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getContents(mimeTree, {
path: '',
type: 'text'
});
break;
case 'body':
if (!item.hasOwnProperty('type')) {
// BODY
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getBody(mimeTree);
} else if (item.path === '' && item.type === 'content') {
// BODY[]
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getContents(mimeTree);
} else {
// BODY[SELECTOR]
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
value = indexer.getContents(mimeTree, item);
}
if (item.partial) {
let len;
if (value && value.type === 'stream') {
value.startFrom = item.partial.startFrom;
value.maxLength = item.partial.maxLength;
len = value.expectedLength;
} else {
value = value.toString('binary').substr(item.partial.startFrom, item.partial.maxLength);
len = value.length;
}
// If start+length is larger than available value length, then do not return the length value
// Instead of BODY[]<10.20> return BODY[]<10> which means that the response is from offset 10 to the end
if (item.original.partial.length === 2 && (item.partial.maxLength - item.partial.startFrom > len)) {
item.original.partial.pop();
}
}
break;
}
values.push(value);
});
return values;
};

View file

@ -0,0 +1,270 @@
'use strict';
let createEnvelope = require('./create-envelope');
class BodyStructure {
constructor(tree, options) {
this.tree = tree;
this.options = options || {};
this.currentPath = '';
this.bodyStructure = this.createBodystructure(this.tree, this.options);
}
create() {
return this.bodyStructure;
}
/**
* Generates an object out of parsed mime tree, that can be
* serialized into a BODYSTRUCTURE string
*
* @param {Object} tree Parsed mime tree (see mimeparser.js for input)
* @param {Object} [options] Optional options object
* @param {Boolean} [options.contentLanguageString] If true, convert single element array to string for Content-Language
* @param {Boolean} [options.upperCaseKeys] If true, use only upper case key names
* @param {Boolean} [options.skipContentLocation] If true, do not include Content-Location in the output
* @param {Boolean} [options.body] If true, skip extension fields (needed for BODY)
* @param {Object} Object structure in the form of BODYSTRUCTURE
*/
createBodystructure(tree, options) {
options = options || {};
let walker = node => {
switch ((node.parsedHeader['content-type'] || {}).type) {
case 'multipart':
return this.processMultipartNode(node, options);
case 'text':
return this.processTextNode(node, options);
case 'message':
if (!options.attachmentRFC822) {
return this.processRFC822Node(node, options);
}
return this.processAttachmentNode(node, options);
default:
return this.processAttachmentNode(node, options);
}
};
return walker(tree);
}
/**
* Generates a list of basic fields any non-multipart part should have
*
* @param {Object} node A tree node of the parsed mime tree
* @param {Object} [options] Optional options object (see createBodystructure for details)
* @return {Array} A list of basic fields
*/
getBasicFields(node, options) {
let bodyType = node.parsedHeader['content-type'] && node.parsedHeader['content-type'].type || null;
let bodySubtype = node.parsedHeader['content-type'] && node.parsedHeader['content-type'].subtype || null;
let contentTransfer = node.parsedHeader['content-transfer-encoding'] || '7bit';
return [
// body type
options.upperCaseKeys ? bodyType && bodyType.toUpperCase() || null : bodyType,
// body subtype
options.upperCaseKeys ? bodySubtype && bodySubtype.toUpperCase() || null : bodySubtype,
// body parameter parenthesized list
node.parsedHeader['content-type'] &&
node.parsedHeader['content-type'].hasParams &&
this.flatten(Object.keys(node.parsedHeader['content-type'].params).map(key => [
options.upperCaseKeys ? key.toUpperCase() : key,
node.parsedHeader['content-type'].params[key]
])) || null,
// body id
node.parsedHeader['content-id'] || null,
// body description
node.parsedHeader['content-description'] || null,
// body encoding
options.upperCaseKeys ? contentTransfer && contentTransfer.toUpperCase() || '7bit' : contentTransfer,
// body size
node.size
];
}
/**
* Generates a list of extension fields any non-multipart part should have
*
* @param {Object} node A tree node of the parsed mime tree
* @param {Object} [options] Optional options object (see createBodystructure for details)
* @return {Array} A list of extension fields
*/
getExtensionFields(node, options) {
options = options || {};
let languageString = node.parsedHeader['content-language'] &&
node.parsedHeader['content-language'].replace(/[ ,]+/g, ',').replace(/^,+|,+$/g, '');
let language = languageString && languageString.split(',') || null;
let data;
// if `contentLanguageString` is true, then use a string instead of single element array
if (language && language.length === 1 && options.contentLanguageString) {
language = language[0];
}
data = [
// body MD5
node.parsedHeader['content-md5'] || null,
// body disposition
node.parsedHeader['content-disposition'] && [
options.upperCaseKeys ?
node.parsedHeader['content-disposition'].value.toUpperCase() :
node.parsedHeader['content-disposition'].value,
node.parsedHeader['content-disposition'].params &&
node.parsedHeader['content-disposition'].hasParams &&
this.flatten(Object.keys(node.parsedHeader['content-disposition'].params).map(key => [
options.upperCaseKeys ? key.toUpperCase() : key,
node.parsedHeader['content-disposition'].params[key]
])) || null
] || null,
// body language
language
];
// if `skipContentLocation` is true, do not include Content-Location in output
//
// NB! RFC3501 has an errata with content-location type, it is described as
// 'A string list' (eg. an array) in RFC but the errata page states
// that it is a string (http://www.rfc-editor.org/errata_search.php?rfc=3501)
// see note for 'Section 7.4.2, page 75'
if (!options.skipContentLocation) {
// body location
data.push(node.parsedHeader['content-location'] || null);
}
return data;
}
/**
* Processes a node with content-type=multipart/*
*
* @param {Object} node A tree node of the parsed mime tree
* @param {Object} [options] Optional options object (see createBodystructure for details)
* @return {Array} BODYSTRUCTURE for a multipart part
*/
processMultipartNode(node, options) {
options = options || {};
let data = (node.childNodes && node.childNodes.map(tree => this.createBodystructure(tree, options)) || [
[]
]).
concat([
// body subtype
options.upperCaseKeys ? node.multipart && node.multipart.toUpperCase() || null : node.multipart,
// body parameter parenthesized list
node.parsedHeader['content-type'] &&
node.parsedHeader['content-type'].hasParams &&
this.flatten(Object.keys(node.parsedHeader['content-type'].params).map(key => [
options.upperCaseKeys ? key.toUpperCase() : key,
node.parsedHeader['content-type'].params[key]
])) || null
]);
if (options.body) {
return data;
} else {
return data.
// skip body MD5 from extension fields
concat(this.getExtensionFields(node, options).slice(1));
}
}
/**
* Processes a node with content-type=text/*
*
* @param {Object} node A tree node of the parsed mime tree
* @param {Object} [options] Optional options object (see createBodystructure for details)
* @return {Array} BODYSTRUCTURE for a text part
*/
processTextNode(node, options) {
options = options || {};
let data = [].concat(this.getBasicFields(node, options)).concat([
node.lineCount
]);
if (!options.body) {
data = data.concat(this.getExtensionFields(node, options));
}
return data;
}
/**
* Processes a non-text, non-multipart node
*
* @param {Object} node A tree node of the parsed mime tree
* @param {Object} [options] Optional options object (see createBodystructure for details)
* @return {Array} BODYSTRUCTURE for the part
*/
processAttachmentNode(node, options) {
options = options || {};
let data = [].concat(this.getBasicFields(node, options));
if (!options.body) {
data = data.concat(this.getExtensionFields(node, options));
}
return data;
}
/**
* Processes a node with content-type=message/rfc822
*
* @param {Object} node A tree node of the parsed mime tree
* @param {Object} [options] Optional options object (see createBodystructure for details)
* @return {Array} BODYSTRUCTURE for a text part
*/
processRFC822Node(node, options) {
options = options || {};
let data = [].concat(this.getBasicFields(node, options));
data.push(createEnvelope(node.message.parsedHeader));
data.push(this.createBodystructure(node.message, options));
data = data.concat(
node.lineCount
).
concat(this.getExtensionFields(node, options));
return data;
}
/**
* Converts all sub-arrays into one level array
* flatten([1,[2,3]]) -> [1,2,3]
*
* @param {Array} arr An array with possible sub-arrays
* @return {Array} Flat array
*/
flatten(arr) {
let result = [];
if (Array.isArray(arr)) {
arr.forEach(elm => {
if (Array.isArray(elm)) {
result = result.concat(this.flatten(elm));
} else {
result.push(elm);
}
});
} else {
result.push(arr);
}
return result;
}
}
// Expose to the world
module.exports = BodyStructure;

View file

@ -0,0 +1,56 @@
'use strict';
// This module converts message structure into an ENVELOPE object
/**
* Convert a message header object to an ENVELOPE object
*
* @param {Object} message A parsed mime tree node
* @return {Object} ENVELOPE compatible object
*/
module.exports = function (header) {
return [
header.date || null,
header.subject || '',
processAddress(header.from),
processAddress(header.sender, header.from),
processAddress(header['reply-to'], header.from),
processAddress(header.to),
processAddress(header.cc),
processAddress(header.bcc),
header['in-reply-to'] || null,
header['message-id'] || null
];
};
/**
* Converts an address object to a list of arrays
* [{name: 'User Name', addres:'user@example.com'}] -> [['User Name', null, 'user', 'example.com']]
*
* @param {Array} arr An array of address objects
* @return {Array} A list of addresses
*/
function processAddress(arr, defaults) {
arr = [].concat(arr || []);
if (!arr.length) {
arr = [].concat(defaults || []);
}
if (!arr.length) {
return null;
}
let result = [];
arr.forEach(addr => {
if (!addr.group) {
result.push([
addr.name || null, null, (addr.address || '').split('@').shift() || null, (addr.address || '').split('@').pop() || null
]);
} else {
// Handle group syntax
result.push([null, null, addr.name || '', null]);
result = result.concat(processAddress(addr.group) || []);
result.push([null, null, null, null]);
}
});
return result;
}

View file

@ -0,0 +1,465 @@
/* eslint no-console: 0 */
'use strict';
let stream = require('stream');
let PassThrough = stream.PassThrough;
let BodyStructure = require('./body-structure');
let createEnvelope = require('./create-envelope');
let parseMimeTree = require('./parse-mime-tree');
let fetch = require('nodemailer-fetch');
let libbase64 = require('libbase64');
let util = require('util');
let LengthLimiter = require('../length-limiter');
class Indexer {
constructor(options) {
this.options = options || {};
this.fetchOptions = this.options.fetchOptions || {};
// create logger
this.logger = this.options.logger || {
info: () => false,
debug: () => false,
error: () => false
};
}
/**
* Returns expected size for a node
*
* @param {Object} mimeTree Parsed mimeTree object (or sub node)
* @param {Boolean} textOnly If true, do not include the message header in the response
* @return {String} Expected message size
*/
getSize(mimeTree, textOnly) {
let size = 0;
let first = true;
let root = true;
// make sure that mixed body + mime gets rebuilt correctly
let append = (data, force) => {
if (Array.isArray(data)) {
data = data.join('\r\n');
}
if (data || force) {
size += new Buffer((first ? '' : '\r\n') + (data || ''), 'binary').length;
first = false;
}
};
let walk = (node, next) => {
if (!textOnly || !root) {
append(filterHeaders(node.header).join('\r\n') + '\r\n');
}
let finalize = () => {
if (node.boundary) {
append('--' + node.boundary + '--\r\n');
}
append();
next();
};
root = false;
if (node.body || node.parsedHeader['x-attachment-stream-url']) {
append(false, true); // force newline
size += node.size;
}
if (node.boundary) {
append('--' + node.boundary);
} else if (node.parsedHeader['x-attachment-stream-url']) {
return finalize();
}
let pos = 0;
let processChildNodes = () => {
if (pos >= node.childNodes.length) {
return finalize();
}
let childNode = node.childNodes[pos++];
walk(childNode, () => {
if (pos < node.childNodes.length) {
append('--' + node.boundary);
}
return processChildNodes();
});
};
if (Array.isArray(node.childNodes)) {
processChildNodes();
} else {
finalize();
}
};
walk(mimeTree, () => false);
return size;
}
/**
* Builds a parsed mime tree into a rfc822 message
*
* @param {Object} mimeTree Parsed mimeTree object
* @param {Boolean} textOnly If true, do not include the message header in the response
* @return {Stream} Message stream
*/
rebuild(mimeTree, textOnly) {
let res = new PassThrough();
let first = true;
let root = true;
let remainder = '';
// make sure that mixed body + mime gets rebuilt correctly
let append = (data, force) => {
if (Array.isArray(data)) {
data = data.join('\r\n');
}
if (remainder || data || force) {
res.write(new Buffer((first ? '' : '\r\n') + (remainder || '') + (data || ''), 'binary'));
first = false;
}
remainder = '';
};
let walk = (node, next) => {
if (!textOnly || !root) {
append(filterHeaders(node.header).join('\r\n') + '\r\n');
}
root = false;
remainder = node.body || '';
let finalize = () => {
if (node.boundary) {
append('--' + node.boundary + '--\r\n');
}
append();
next();
};
if (node.boundary) {
append('--' + node.boundary);
} else if (node.parsedHeader['x-attachment-stream-url']) {
let streamUrl = node.parsedHeader['x-attachment-stream-url'].replace(/^<|>$/g, '');
let streamEncoded = /^\s*YES\s*$/i.test(node.parsedHeader['x-attachment-stream-encoded']);
append(false, true); // force newline between header and contents
let headers = {};
if (this.fetchOptions.userAgent) {
headers['User-Agent'] = this.fetchOptions.userAgent;
}
if (this.fetchOptions.cookies) {
headers.Cookie = this.fetchOptions.cookies.get(streamUrl);
}
this.logger.debug('Fetching <%s>\nHeaders: %s', streamUrl, util.inspect(headers, false, 22));
let limiter = new LengthLimiter(node.size);
let fetchStream = fetch(streamUrl, {
userAgent: this.fetchOptions.userAgent,
maxRedirects: this.fetchOptions.maxRedirects,
cookies: this.fetchOptions.cookies,
timeout: 60 * 1000 // timeout after one minute of inactivity
});
fetchStream.on('error', err => {
res.emit('error', err);
});
limiter.on('error', err => {
res.emit('error', err);
});
limiter.on('end', () => finalize());
if (!streamEncoded) {
let b64encoder = new libbase64.Encoder();
b64encoder.on('error', err => {
res.emit('error', err);
});
// encode stream as base64
fetchStream.pipe(b64encoder).pipe(limiter).pipe(res, {
end: false
});
} else {
// already encoded, pipe directly to output
fetchStream.pipe(limiter).pipe(res, {
end: false
});
}
return;
}
let pos = 0;
let processChildNodes = () => {
if (pos >= node.childNodes.length) {
return finalize();
}
let childNode = node.childNodes[pos++];
walk(childNode, () => {
if (pos < node.childNodes.length) {
append('--' + node.boundary);
}
setImmediate(processChildNodes);
});
};
if (Array.isArray(node.childNodes)) {
processChildNodes();
} else {
finalize();
}
};
setImmediate(walk.bind(null, mimeTree, () => {
res.end();
}));
return {
type: 'stream',
value: res,
expectedLength: this.getSize(mimeTree, textOnly)
};
}
/**
* Parses structured MIME tree from a rfc822 message source
*
* @param {String|Buffer} rfc822 E-mail message as 'binary'-string or Buffer
* @return {Object} Parsed mime tree
*/
parseMimeTree(rfc822) {
return parseMimeTree(rfc822);
}
/**
* Generates IMAP compatible BODY object from message tree
*
* @param {Object} mimeTree Parsed mimeTree object
* @return {Array} BODY object as a structured Array
*/
getBody(mimeTree) {
// BODY BODYSTRUCTURE without extension data
let body = new BodyStructure(mimeTree, {
upperCaseKeys: true,
body: true
});
return body.create();
}
/**
* Generates IMAP compatible BODYSTRUCUTRE object from message tree
*
* @param {Object} mimeTree Parsed mimeTree object
* @return {Array} BODYSTRUCTURE object as a structured Array
*/
getBodyStructure(mimeTree) {
// full BODYSTRUCTURE
let bodystructure = new BodyStructure(mimeTree, {
upperCaseKeys: true,
skipContentLocation: false
});
return bodystructure.create();
}
/**
* Generates IMAP compatible ENVELOPE object from message headers
*
* @param {Object} mimeTree Parsed mimeTree object
* @return {Array} ENVELOPE object as a structured Array
*/
getEnvelope(mimeTree) {
return createEnvelope(mimeTree.parsedHeader || {});
}
/**
* Resolves numeric path to a node in the parsed MIME tree
*
* @param {Object} mimeTree Parsed mimeTree object
* @param {String} path Dot-separated numeric path
* @return {Object} Mime node
*/
resolveContentNode(mimeTree, path) {
if (!mimeTree.childNodes && path === '1') {
path = '';
}
let pathNumbers = (path || '').toString().split('.');
let contentNode = mimeTree;
let pathNumber;
while ((pathNumber = pathNumbers.shift())) {
pathNumber = Number(pathNumber) - 1;
if (contentNode.message) {
// redirect to message/rfc822
contentNode = contentNode.message;
}
if (contentNode.childNodes && contentNode.childNodes[pathNumber]) {
contentNode = contentNode.childNodes[pathNumber];
} else {
return false;
}
}
return contentNode;
}
bodyQuery(mimeTree, selector, callback) {
let data = this.getContents(mimeTree, selector);
if (data && data.type === 'stream') {
let sent = false;
let buffers = [];
let buflen = 0;
data.value.on('readable', () => {
let buf;
while ((buf = data.value.read())) {
buffers.push(buf);
buflen += buf.length;
}
});
data.value.on('error', err => {
if (sent) {
return;
}
sent = true;
return callback(err);
});
data.value.on('end', () => {
if (sent) {
return;
}
sent = true;
return callback(null, Buffer.concat(buffers, buflen));
});
} else {
return setImmediate(() => callback(null, new Buffer((data || '').toString(), 'binary')));
}
}
/**
* Get node contents
*
* *selector* is an object with the following properties:
* * *path* numeric path 1.2.3
* * *type* - one of content|header|header.fields|header.fields.not|text|mime
* * *headers* - an array of headers to include/exclude
*
* @param {Object} mimeTree Parsed mimeTree object
* @param {Object} selector What data to return
* @return {String} node contents
*/
getContents(mimeTree, selector) {
let node = mimeTree;
if (typeof selector === 'string') {
selector = {
type: selector
};
}
selector = selector || {
type: ''
};
if (selector.path) {
node = this.resolveContentNode(mimeTree, selector.path);
}
if (!node) {
return '';
}
switch (selector.type) {
case '':
case 'content':
if (!selector.path) {
// BODY[]
return this.rebuild(node);
}
// BODY[1.2.3]
return this.rebuild(node, true);
case 'header':
if (!selector.path) {
// BODY[HEADER] mail header
return filterHeaders(node.header).join('\r\n') + '\r\n\r\n';
} else if (node.message) {
// BODY[1.2.3.HEADER] embedded message/rfc822 header
return (node.message.header || []).join('\r\n') + '\r\n\r\n';
}
return '';
case 'header.fields':
// BODY[HEADER.FIELDS.NOT (Key1 Key2 KeyN)] only selected header keys
if (!selector.headers || !selector.headers.length) {
return '\r\n\r\n';
}
return filterHeaders(node.header).filter(line => {
let key = line.split(':').shift().toLowerCase().trim();
return selector.headers.indexOf(key) >= 0;
}).join('\r\n') + '\r\n\r\n';
case 'header.fields.not':
// BODY[HEADER.FIELDS.NOT (Key1 Key2 KeyN)] all but selected header keys
if (!selector.headers || !selector.headers.length) {
return filterHeaders(node.header).join('\r\n') + '\r\n\r\n';
}
return filterHeaders(node.header).filter(line => {
let key = line.split(':').shift().toLowerCase().trim();
return selector.headers.indexOf(key) < 0;
}).join('\r\n') + '\r\n\r\n';
case 'mime':
// BODY[1.2.3.MIME] mime node header
return filterHeaders(node.header).join('\r\n') + '\r\n\r\n';
case 'text':
if (!selector.path) {
// BODY[TEXT] mail body without headers
return this.rebuild(node, true);
} else if (node.message) {
// BODY[1.2.3.TEXT] embedded message/rfc822 body without headers
return this.rebuild(node.message, true);
}
return '';
default:
return '';
}
}
}
function filterHeaders(headers) {
headers = headers || [];
if (!Array.isArray(headers)) {
headers = [].concat(headers || []);
}
return headers.filter(header => !/^X-Attachment-Stream/i.test(header));
}
module.exports = Indexer;

View file

@ -0,0 +1,328 @@
'use strict';
let addressparser = require('addressparser');
/**
* Parses a RFC822 message into a structured object (JSON compatible)
*
* @constructor
* @param {String|Buffer} rfc822 Raw body of the message
*/
class MIMEParser {
constructor(rfc822) {
// ensure the input is a binary string
this.rfc822 = (rfc822 || '').toString('binary');
this._br = '';
this._pos = 0;
this.rawBody = '';
this.tree = {
childNodes: []
};
this._node = this.createNode(this.tree);
}
/**
* Parses the message, line by line
*/
parse() {
let line, prevBr = '';
// keep parsing until the last linebreak is not a string (no linebreaks anymore)
while (typeof this._br === 'string') {
line = this.readLine();
switch (this._node.state) {
case 'header': // process header section
if (this.rawBody) {
this.rawBody += prevBr + line;
}
if (!line) {
this.processNodeHeader();
this.processContentType();
this._node.state = 'body';
} else {
this._node.header.push(line);
}
break;
case 'body': // process body section
this.rawBody += prevBr + line;
if (this._node.parentBoundary && (line === '--' + this._node.parentBoundary || line === '--' + this._node.parentBoundary + '--')) {
if (this._node.parsedHeader['content-type'].value === 'message/rfc822') {
this._node.message = module.exports(this._node.body.join(''));
}
if (line === '--' + this._node.parentBoundary) {
this._node = this.createNode(this._node.parentNode);
} else {
this._node = this._node.parentNode;
}
} else if (this._node.boundary && line === '--' + this._node.boundary) {
this._node = this.createNode(this._node);
} else {
// push the line with previous linebreak value
// if the array is joined together to a one string,
// then the linebreaks in the string are the 'original' ones
this._node.body.push((this._node.body.length ? prevBr : '') + line);
}
break;
default: // never should be reached
throw new Error('Unexpected state');
}
// store the linebreak for later usage
prevBr = this._br;
}
}
/**
* Reads a line from the message body
*
* @return {String|Boolean} A line from the message
*/
readLine() {
let match = this.rfc822.substr(this._pos).match(/(.*?)(\r?\n|$)/);
if (match) {
this._br = match[2] || false;
this._pos += match[0].length;
return match[1];
}
return false;
}
/**
* Join body arrays into strings. Removes unnecessary fields
* from the tree (circular references prohibit conversion to JSON)
*/
finalizeTree() {
let walker = node => {
if (node.body) {
let lineCount = node.body.length;
node.body = node.body.join('').
// ensure proper line endings
replace(/\r?\n/g, '\r\n');
node.size = this.getNodeSize(node);
node.lineCount = this.getLineCount(node, lineCount);
}
node.childNodes.forEach(walker);
// remove unneeded properties
delete node.parentNode;
delete node.state;
if (!node.childNodes.length) {
delete node.childNodes;
}
delete node.parentBoundary;
};
walker(this.tree);
}
getNodeSize(node) {
let bodyLength = (node.body || '').length;
let streamSize = 0;
let streamEncoded = /^\s*YES\s*$/i.test(node.parsedHeader['x-attachment-stream-encoded']);
if (node.parsedHeader['x-attachment-stream-url']) {
streamSize = Number(node.parsedHeader['x-attachment-stream-size']) || 0;
if (!streamEncoded) {
// stream needs base64 encoding, calculate post-encoded size
streamSize = Math.ceil(streamSize / 3 * 4); // convert to base64 length
if (streamSize % 4) {
// add base64 padding
streamSize += (4 - (streamSize % 4));
}
streamSize += Math.floor(streamSize / 76) * 2; // add newlines
}
}
return streamSize + bodyLength;
}
getLineCount(node, lineCount) {
if (node.parsedHeader['x-attachment-stream-lines']) {
// use pre-calculated line count
return Math.max(lineCount - 1, 0) + (Number(node.parsedHeader['x-attachment-stream-lines']) || 0);
} else if (node.parsedHeader['x-attachment-stream-url']) {
// calculate line count for standard base64 encoded content
let streamSize = this.getNodeSize(node);
return Math.max(lineCount - 1, 0) + Math.ceil(streamSize / 78);
}
return lineCount;
}
/**
* Creates a new node with default values for the parse tree
*/
createNode(parentNode) {
let node = {
state: 'header',
childNodes: [],
header: [],
parsedHeader: {},
body: [],
multipart: false,
parentBoundary: parentNode.boundary,
boundary: false,
parentNode
};
parentNode.childNodes.push(node);
return node;
}
/**
* Processes header lines. Splits lines to key-value pairs
* and processes special values
*/
processNodeHeader() {
let key, value;
for (let i = this._node.header.length - 1; i >= 0; i--) {
if (i && this._node.header[i].match(/^\s/)) {
this._node.header[i - 1] = this._node.header[i - 1] + '\r\n' + this._node.header[i];
this._node.header.splice(i, 1);
} else {
value = this._node.header[i].split(':');
key = (value.shift() || '').trim().toLowerCase();
value = value.join(':').trim();
if (key in this._node.parsedHeader) {
if (Array.isArray(this._node.parsedHeader[key])) {
this._node.parsedHeader[key].unshift(value);
} else {
this._node.parsedHeader[key] = [value, this._node.parsedHeader[key]];
}
} else {
this._node.parsedHeader[key] = value.replace(/\s*\r?\n\s*/g, ' ');
}
}
}
// always ensure the presence of Content-Type
if (!this._node.parsedHeader['content-type']) {
this._node.parsedHeader['content-type'] = 'text/plain';
}
// parse additional params for Content-Type and Content-Disposition
['content-type', 'content-disposition'].forEach(key => {
if (this._node.parsedHeader[key]) {
this._node.parsedHeader[key] = this.parseValueParams([].concat(this._node.parsedHeader[key] || []).pop());
}
});
// ensure single value for selected fields
['content-transfer-encoding', 'content-id', 'content-description', 'content-language', 'content-md5', 'content-location'].forEach(key => {
if (Array.isArray(this._node.parsedHeader[key])) {
this._node.parsedHeader[key] = this._node.parsedHeader[key].pop();
}
});
// Parse address fields (join several fields with same key)
['from', 'sender', 'reply-to', 'to', 'cc', 'bcc'].forEach(key => {
let addresses = [];
if (this._node.parsedHeader[key]) {
[].concat(this._node.parsedHeader[key] || []).forEach(value => {
if (value) {
addresses = addresses.concat(addressparser(value) || []);
}
});
this._node.parsedHeader[key] = addresses;
}
});
}
/**
* Splits a value to an object.
* eg. 'text/plain; charset=utf-8' -> {value: 'text/plain', params:{charset: 'utf-8'}}
*
* @param {String} headerValue A string value for a header key
* @return {Object} Parsed value
*/
parseValueParams(headerValue) {
let data = {
value: '',
type: '',
subtype: '',
params: {}
},
match, processEncodedWords = {};
(headerValue || '').split(';').forEach((part, i) => {
let key, value;
if (!i) {
data.value = part.trim();
data.subtype = data.value.split('/');
data.type = (data.subtype.shift() || '').toLowerCase();
data.subtype = data.subtype.join('/');
return;
}
value = part.split('=');
key = (value.shift() || '').trim().toLowerCase();
value = value.join('=').replace(/^['"\s]*|['"\s]*$/g, '');
// This regex allows for an optional trailing asterisk, for headers
// which are encoded with lang/charset info as well as a continuation.
// See https://tools.ietf.org/html/rfc2231 section 4.1.
if ((match = key.match(/^([^*]+)\*(\d)?\*?$/))) {
if (!processEncodedWords[match[1]]) {
processEncodedWords[match[1]] = [];
}
processEncodedWords[match[1]][Number(match[2]) || 0] = value;
} else {
data.params[key] = value;
}
data.hasParams = true;
});
// convert extended mime word into a regular one
Object.keys(processEncodedWords).forEach(key => {
let charset = '';
let value = '';
processEncodedWords[key].forEach(val => {
let parts = val.split('\'');
charset = charset || parts.shift();
value += (parts.pop() || '').replace(/%/g, '=');
});
data.params[key] = '=?' + (charset || 'ISO-8859-1').toUpperCase() + '?Q?' + value + '?=';
});
return data;
}
/**
* Checks Content-Type value for the current tree node.
*/
processContentType() {
if (!this._node.parsedHeader['content-type']) {
return;
}
if (this._node.parsedHeader['content-type'].type === 'multipart' && this._node.parsedHeader['content-type'].params.boundary) {
this._node.multipart = this._node.parsedHeader['content-type'].subtype;
this._node.boundary = this._node.parsedHeader['content-type'].params.boundary;
}
}
}
module.exports = function (rfc822) {
let parser = new MIMEParser(rfc822);
let response;
parser.parse();
parser.finalizeTree();
response = parser.tree.childNodes[0] || false;
return response;
};

View file

@ -0,0 +1,75 @@
'use strict';
let streams = require('stream');
let Transform = streams.Transform;
// make sure that a stream piped to this transform stream
// always emits a fixed amounts of bytes. Either by truncating
// input or emitting padding characters
class LengthLimiter extends Transform {
constructor(expectedLength, padding, startFrom, byteCounter) {
super();
this.expectedLength = expectedLength;
this.padding = padding || ' ';
this.byteCounter = byteCounter || 0;
this.startFrom = startFrom || 0;
this.finished = false;
Transform.call(this);
}
_transform(chunk, encoding, done) {
if (encoding !== 'buffer') {
chunk = new Buffer(chunk, encoding);
}
if (!chunk || !chunk.length || this.finished) {
return done();
}
// not yet at allowed position
if (chunk.length + this.byteCounter <= this.startFrom) {
// ignore
this.byteCounter += chunk.length;
return done();
}
// start emitting at middle of chunk
if (this.byteCounter < this.startFrom) {
// split the chunk and ignore the first part
chunk = chunk.slice(this.startFrom - this.byteCounter);
this.byteCounter += (this.startFrom - this.byteCounter);
}
// can emit full chunk
if (chunk.length + this.byteCounter <= this.expectedLength) {
this.byteCounter += chunk.length;
this.push(chunk);
if (this.byteCounter >= this.expectedLength) {
this.finished = true;
this.emit('done', false);
}
return setImmediate(done);
}
// stop emitting in the middle of chunk
let buf = chunk.slice(0, this.expectedLength - this.byteCounter);
let remaining = chunk.slice(this.expectedLength - this.byteCounter);
this.push(buf);
this.finished = true;
this.emit('done', remaining);
return setImmediate(done);
}
_flush(done) {
if (!this.finished) {
// add padding if incoming stream stopped too early
let buf = new Buffer(this.padding.repeat(this.expectedLength - this.byteCounter));
this.push(buf);
this.finished = true;
}
done();
}
}
module.exports = LengthLimiter;

View file

@ -0,0 +1,150 @@
'use strict';
let crypto = require('crypto');
let EventEmitter = require('events').EventEmitter;
// Expects that the folder listing is a Map
class MemoryNotifier extends EventEmitter {
constructor(options) {
super();
this.folders = options.folders || new Map();
let logfunc = (...args) => {
let level = args.shift() || 'DEBUG';
let message = args.shift() || '';
console.log([level].concat(message || '').join(' '), ...args); // eslint-disable-line no-console
};
this.logger = options.logger || {
info: logfunc.bind(null, 'INFO'),
debug: logfunc.bind(null, 'DEBUG'),
error: logfunc.bind(null, 'ERROR')
};
this._listeners = new EventEmitter();
this._listeners.setMaxListeners(0);
EventEmitter.call(this);
}
/**
* Generates hashed event names for mailbox:username pairs
*
* @param {String} mailbox
* @param {String} username
* @returns {String} md5 hex
*/
_eventName(mailbox, username) {
return crypto.createHash('md5').update(username + ':' + mailbox).digest('hex');
}
/**
* Registers an event handler for mailbox:username events
*
* @param {String} username
* @param {String} mailbox
* @param {Function} handler Function to run once there are new entries in the journal
*/
addListener(session, mailbox, handler) {
let eventName = this._eventName(session.user.username, mailbox);
this._listeners.addListener(eventName, handler);
this.logger.debug('New journal listener for %s ("%s:%s")', eventName, session.user.username, mailbox);
}
/**
* Unregisters an event handler for mailbox:username events
*
* @param {String} username
* @param {String} mailbox
* @param {Function} handler Function to run once there are new entries in the journal
*/
removeListener(session, mailbox, handler) {
let eventName = this._eventName(session.user.username, mailbox);
this._listeners.removeListener(eventName, handler);
this.logger.debug('Removed journal listener from %s ("%s:%s")', eventName, session.user.username, mailbox);
}
/**
* Stores multiple journal entries to db
*
* @param {String} username
* @param {String} mailbox
* @param {Array|Object} entries An array of entries to be journaled
* @param {Function} callback Runs once the entry is either stored or an error occurred
*/
addEntries(username, mailbox, entries, callback) {
let folder = this.folders.get(mailbox);
if (!folder) {
return callback(null, new Error('Selected mailbox does not exist'));
}
if (entries && !Array.isArray(entries)) {
entries = [entries];
} else if (!entries || !entries.length) {
return callback(null, false);
}
// store entires in the folder object
if (!folder.journal) {
folder.journal = [];
}
entries.forEach(entry => {
entry.modseq = ++folder.modifyIndex;
folder.journal.push(entry);
});
setImmediate(callback);
}
/**
* Sends a notification that there are new updates in the selected mailbox
*
* @param {String} username
* @param {String} mailbox
*/
fire(username, mailbox, payload) {
let eventName = this._eventName(username, mailbox);
setImmediate(() => {
this._listeners.emit(eventName, payload);
});
}
/**
* Returns all entries from the journal that have higher than provided modification index
*
* @param {String} username
* @param {String} mailbox
* @param {Number} modifyIndex Last known modification id
* @param {Function} callback Returns update entries as an array
*/
getUpdates(session, mailbox, modifyIndex, callback) {
modifyIndex = Number(modifyIndex) || 0;
if (!this.folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = this.folders.get(mailbox);
let minIndex = folder.journal.length;
for (let i = folder.journal.length - 1; i >= 0; i--) {
if (folder.journal[i].modseq > modifyIndex) {
minIndex = i;
} else {
break;
}
}
return callback(null, folder.journal.slice(minIndex));
}
}
module.exports = MemoryNotifier;

View file

@ -0,0 +1,22 @@
-- inserts JSON values to a sorted set where score value is an incrementing number resolved from a KEYS[1].modifyIndex
-- check if the mailbox even exists
if not redis.call('exists', KEYS[1]) then
return {err='Selected mailbox does not exist'}
end;
local len = table.getn(ARGV);
-- do a single increment to get id values for all elements instead of incrementing it one by one
local score = redis.call('hincrby', KEYS[1], 'modifyIndex', len) - len;
for i = 1, #ARGV do
-- we include modification index in the stored value to ensure that all values are always unique
-- otherwise adding new element with the same data does not insert a new entry but overrides
-- an existing one
redis.call('zadd', KEYS[2], score + i, tostring(score + i) .. ':' .. ARGV[i]);
end;
-- return the largest modification index
return score + len;

View file

@ -0,0 +1,244 @@
'use strict';
let redis = require('redis');
let EventEmitter = require('events').EventEmitter;
let crypto = require('crypto');
let fs = require('fs');
let scripts = {
addEntries: fs.readFileSync(__dirname + '/add-entries.lua')
};
// Assumes that there are following hash keys in Redis:
// u:[username]:folder:[md5(path)]
// with the following key:
// modifyIndex: Number
class RedisNotifier extends EventEmitter {
constructor(options) {
super();
options = options || {};
this.options = {
port: options.port || 6379,
host: options.host || 'localhost',
db: options.db || 0,
prefix: options.prefix || 'imap:'
};
let logfunc = (...args) => {
let level = args.shift() || 'DEBUG';
let message = args.shift() || '';
console.log([level].concat(message || '').join(' '), ...args); // eslint-disable-line no-console
};
this.logger = options.logger || {
info: logfunc.bind(null, 'INFO'),
debug: logfunc.bind(null, 'DEBUG'),
error: logfunc.bind(null, 'ERROR')
};
// we need two db connections as subscriber can't manage data
this._db = redis.createClient(options.port, options.host);
this._subscriber = redis.createClient(options.port, options.host);
this._pubsubListeners = new Map();
this._listeners = new EventEmitter();
this._listeners.setMaxListeners(0);
this._subscriber.on('message', (channel, message) => {
try {
message = JSON.parse(message);
} catch (E) {
// ignore
}
this.logger.debug(
'Journal update notification for %s, updating %s subscribers',
channel.slice(this.options.prefix.length),
this._listeners.listenerCount(channel.slice(this.options.prefix.length)));
this._listeners.emit(channel.slice(this.options.prefix.length), message);
});
EventEmitter.call(this);
}
/**
* Generates hashed event names for mailbox:username pairs
*
* @param {String} username
* @param {String} mailbox
* @returns {String} md5 hex
*/
_eventName(username, mailbox) {
return crypto.createHash('md5').update(username + ':' + mailbox).digest('hex');
}
/**
* Registers an event handler for mailbox:username events
*
* @param {String} username
* @param {String} mailbox
* @param {Function} handler Function to run once there are new entries in the journal
*/
addListener(session, mailbox, handler) {
let eventName = this._eventName(session.user.username, mailbox);
this._listeners.addListener(eventName, handler);
if (!this._pubsubListeners.has(eventName)) {
this._pubsubListeners.set(eventName, 1);
this._subscriber.subscribe(this.options.prefix + eventName);
} else {
this._pubsubListeners.set(eventName, this._pubsubListeners.get(eventName) + 1);
}
this.logger.debug('New journal listener for %s ("%s:%s", total %s subscribers)', eventName, session.user.username, mailbox, this._listeners.listenerCount(eventName));
}
/**
* Unregisters an event handler for mailbox:username events
*
* @param {String} username
* @param {String} mailbox
* @param {Function} handler Function to run once there are new entries in the journal
*/
removeListener(session, mailbox, handler) {
let count, eventName = this._eventName(session.user.username, mailbox);
this._listeners.removeListener(eventName, handler);
if (this._pubsubListeners.has(eventName) && (count = this._pubsubListeners.get(eventName)) && count > 0) {
count--;
if (!count) {
this._subscriber.unsubscribe(this.options.prefix + eventName);
this._pubsubListeners.delete(eventName);
} else {
this._pubsubListeners.set(eventName, 1);
}
this.logger.debug('Removed journal listener from %s ("%s:%s", total %s subscribers)', eventName, session.user.username, mailbox, this._listeners.listenerCount(eventName));
}
}
/**
* Stores multiple journal entries to db
*
* @param {String} username
* @param {String} mailbox
* @param {Array|Object} entries An array of entries to be journaled
* @param {Function} callback Runs once the entry is either stored or an error occurred
*/
addEntries(username, mailbox, entries, callback) {
let mailboxHash = crypto.createHash('md5').update(mailbox).digest('hex');
if (entries && !Array.isArray(entries)) {
entries = [entries];
} else if (!entries || !entries.length) {
return callback(null, false);
}
entries = entries.map(entry => JSON.stringify(entry));
this.logger.debug('Adding journal entries for %s (%s)\n%s', mailbox, mailboxHash, entries.join('\n'));
this._db.multi().
select(this.options.db).
eval([
scripts.addEntries,
2,
'u:' + username + ':folder:' + mailboxHash,
'u:' + username + ':journal:' + mailboxHash
].concat(entries)).
exec(err => {
if (err) {
return callback(err);
}
return callback(null, true);
});
}
/**
* Sends a notification that there are new updates in the selected mailbox
*
* @param {String} username
* @param {String} mailbox
*/
fire(username, mailbox, payload) {
let eventName = this._eventName(username, mailbox);
payload = payload || false;
setImmediate(this._db.publish.bind(this._db, this.options.prefix + eventName, JSON.stringify(payload)));
}
/**
* Returns all entries from the journal that have higher than provided modification index
*
* @param {String} username
* @param {String} mailbox
* @param {Number} modifyIndex Last known modification id
* @param {Function} callback Returns update entries as an array
*/
getUpdates(session, mailbox, modifyIndex, callback) {
modifyIndex = Number(modifyIndex) || 0;
let mailboxHash = crypto.createHash('md5').update(mailbox).digest('hex');
let username = session.user.username;
this._db.multi().
select(this.options.db).
exists('u:' + username + ':journal:' + mailboxHash).
zrangebyscore('u:' + username + ':journal:' + mailboxHash, modifyIndex + 1, Infinity).
exec((err, replies) => {
let updates;
this.logger.debug('[%s] Loaded journal updates for "%s:%s" since %s', session.id, username, mailbox, modifyIndex + 1);
if (err) {
return callback(err);
}
if (!replies || !replies[1]) {
return callback(null, 'NONEXISTENT');
}
updates = (replies[2] || []).
map(entry => {
let data;
let m = (entry || '').toString().match(/^(\d+)\:/);
if (!m) {
// invalidly formatted entry
this.logger.debug('[%s] Invalidly formatted entry for "%s:%s" (%s)', session.id, username, mailbox, (entry).toString());
return false;
}
try {
data = JSON.parse(entry.substr(m[0].length));
data.modseq = Number(m[1]) || false;
// we mess around with json in redis lua but lua does not make
// a distinction between an object and an array, if an array
// is empty then it will be invalidly detected as an object
if (data.flags && !Array.isArray(data.flags)) {
data.flags = [];
}
} catch (E) {
this.logger.error('[%s] Failed parsing journal update for "%s:%s" (%s): %s', session.id, username, mailbox, entry.substr(m[0].length), E.message);
}
return data;
}).filter(entry =>
// only include entries with data
(entry && entry.uid)
);
this.logger.debug('[%s] Processing journal updates for "%s:%s": %s', session.id, username, mailbox, JSON.stringify(updates));
callback(null, updates);
});
}
}
module.exports = RedisNotifier;

210
imap-core/lib/search.js Normal file
View file

@ -0,0 +1,210 @@
'use strict';
const Indexer = require('./indexer/indexer');
let indexer = new Indexer();
module.exports.matchSearchQuery = matchSearchQuery;
let queryHandlers = {
// atom beautify uses invalid indentation that messes up shorthand methods
/*eslint-disable object-shorthand */
// always matches
all: function () {
return true;
},
// matches if the message object includes (exists:true) or does not include (exists:false) specifiec flag
flag: function (message, query) {
let pos = [].concat(message.flags || []).indexOf(query.value);
return query.exists ? pos >= 0 : pos < 0;
},
// matches message receive date
internaldate: function (message, query) {
switch (query.operator) {
case '<':
return getShortDate(message.internaldate) < getShortDate(query.value);
case '=':
return getShortDate(message.internaldate) === getShortDate(query.value);
case '>=':
return getShortDate(message.internaldate) >= getShortDate(query.value);
}
return false;
},
// matches message header date
date: function (message, query) {
let mimeTree = message.mimeTree;
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw);
}
let date = mimeTree.parsedHeader.date || message.internaldate;
switch (query.operator) {
case '<':
return getShortDate(date) < getShortDate(query.value);
case '=':
return getShortDate(date) === getShortDate(query.value);
case '>=':
return getShortDate(date) >= getShortDate(query.value);
}
return false;
},
// matches message body
body: function (message, query) {
let body = (message.raw || '').toString();
let bodyStart = body.match(/\r?\r?\n/);
if (!bodyStart) {
return false;
}
return body.substr(bodyStart.index + bodyStart[0].length).toLowerCase().indexOf((query.value || '').toString().toLowerCase()) >= 0;
},
// matches message source
text: function (message, query) {
return (message.raw || '').toString().toLowerCase().indexOf((query.value || '').toString().toLowerCase()) >= 0;
},
// matches message UID number. Sequence queries are also converted to UID queries
uid: function (message, query) {
return query.value.indexOf(message.uid) >= 0;
},
// matches message source size
size: function (message, query) {
let raw = message.raw || '';
switch (query.operator) {
case '<':
return raw.length < query.value;
case '=':
return raw.length === query.value;
case '>':
return raw.length > query.value;
}
return false;
},
// matches message headers
header: function (message, query) {
let mimeTree = message.mimeTree;
if (!mimeTree) {
mimeTree = indexer.parseMimeTree(message.raw || '');
}
let headers = (mimeTree.header || []);
let header = query.header;
let term = (query.value || '').toString().toLowerCase();
let key, value, parts;
for (let i = 0, len = headers.length; i < len; i++) {
parts = headers[i].split(':');
key = (parts.shift() || '').trim().toLowerCase();
if (/^X-Attachment-Stream/i.test(key)) {
// skip special headers
continue;
}
value = (parts.join(':') || '');
if (key === header && (!term || value.toLowerCase().indexOf(term) >= 0)) {
return true;
}
}
return false;
},
// matches messages with modifyIndex exual or greater than criteria
modseq: function (message, query) {
return message.modseq >= query.value;
},
// charset argument is ignored
charset: function () {
return true;
}
/*eslint-enable object-shorthand */
};
/**
* Returns a date object with time set to 00:00 on UTC timezone
*
* @param {String|Date} date Date to convert
* @returns {Date} Date object without time
*/
function getShortDate(date) {
date = date || new Date();
if (typeof date === 'string' || typeof date === 'number') {
date = new Date(date);
}
return date.toISOString().substr(0, 10);
}
/**
* Checks if a specific search term match the message or not
*
* @param {Object} message Stored message object
* @param {Object} query Query term object
* @returns {Boolean} Term matched (true) or not (false)
*/
function matchSearchTerm(message, query) {
if (Array.isArray(query)) {
// AND, all terms need to match
return matchSearchQuery(message, query);
}
if (!query || typeof query !== 'object') {
// unknown query term
return false;
}
switch (query.key) {
case 'or':
// OR, only single match needed
for (let i = query.value.length - 1; i >= 0; i--) {
if (matchSearchTerm(message, query.value[i])) {
return true;
}
}
return false;
case 'not':
// return reverse match
return !matchSearchTerm(message, query.value);
default:
// check if there is a handler for the term and use it
if (queryHandlers.hasOwnProperty(query.key)) {
return queryHandlers[query.key](message, query);
}
return false;
}
}
/**
* Traverses query tree and checks if all query terms match or not. Stops on first false match occurence
*
* @param {Object} message Stored message object
* @param {Object} query Query term object
* @returns {Boolean} Term matched (true) or not (false)
*/
function matchSearchQuery(message, query) {
if (!Array.isArray(query)) {
query = [].concat(query || []);
}
for (let i = 0, len = query.length; i < len; i++) {
if (!matchSearchTerm(message, query[i])) {
return false;
}
}
return true;
}

View file

@ -0,0 +1,98 @@
'use strict';
// Expose to the world
module.exports = getTLSOptions;
let tlsDefaults = {
key: '-----BEGIN RSA PRIVATE KEY-----\n' +
'MIIEpAIBAAKCAQEA6Z5Qqhw+oWfhtEiMHE32Ht94mwTBpAfjt3vPpX8M7DMCTwHs\n' +
'1xcXvQ4lQ3rwreDTOWdoJeEEy7gMxXqH0jw0WfBx+8IIJU69xstOyT7FRFDvA1yT\n' +
'RXY2yt9K5s6SKken/ebMfmZR+03ND4UFsDzkz0FfgcjrkXmrMF5Eh5UXX/+9YHeU\n' +
'xlp0gMAt+/SumSmgCaysxZLjLpd4uXz+X+JVxsk1ACg1NoEO7lWJC/3WBP7MIcu2\n' +
'wVsMd2XegLT0gWYfT1/jsIH64U/mS/SVXC9QhxMl9Yfko2kx1OiYhDxhHs75RJZh\n' +
'rNRxgfiwgSb50Gw4NAQaDIxr/DJPdLhgnpY6UQIDAQABAoIBAE+tfzWFjJbgJ0ql\n' +
's6Ozs020Sh4U8TZQuonJ4HhBbNbiTtdDgNObPK1uNadeNtgW5fOeIRdKN6iDjVeN\n' +
'AuXhQrmqGDYVZ1HSGUfD74sTrZQvRlWPLWtzdhybK6Css41YAyPFo9k4bJ2ZW2b/\n' +
'p4EEQ8WsNja9oBpttMU6YYUchGxo1gujN8hmfDdXUQx3k5Xwx4KA68dveJ8GasIt\n' +
'd+0Jd/FVwCyyx8HTiF1FF8QZYQeAXxbXJgLBuCsMQJghlcpBEzWkscBR3Ap1U0Zi\n' +
'4oat8wrPZGCblaA6rNkRUVbc/+Vw0stnuJ/BLHbPxyBs6w495yBSjBqUWZMvljNz\n' +
'm9/aK0ECgYEA9oVIVAd0enjSVIyAZNbw11ElidzdtBkeIJdsxqhmXzeIFZbB39Gd\n' +
'bjtAVclVbq5mLsI1j22ER2rHA4Ygkn6vlLghK3ZMPxZa57oJtmL3oP0RvOjE4zRV\n' +
'dzKexNGo9gU/x9SQbuyOmuauvAYhXZxeLpv+lEfsZTqqrvPUGeBiEQcCgYEA8poG\n' +
'WVnykWuTmCe0bMmvYDsWpAEiZnFLDaKcSbz3O7RMGbPy1cypmqSinIYUpURBT/WY\n' +
'wVPAGtjkuTXtd1Cy58m7PqziB7NNWMcsMGj+lWrTPZ6hCHIBcAImKEPpd+Y9vGJX\n' +
'oatFJguqAGOz7rigBq6iPfeQOCWpmprNAuah++cCgYB1gcybOT59TnA7mwlsh8Qf\n' +
'bm+tSllnin2A3Y0dGJJLmsXEPKtHS7x2Gcot2h1d98V/TlWHe5WNEUmx1VJbYgXB\n' +
'pw8wj2ACxl4ojNYqWPxegaLd4DpRbtW6Tqe9e47FTnU7hIggR6QmFAWAXI+09l8y\n' +
'amssNShqjE9lu5YDi6BTKwKBgQCuIlKGViLfsKjrYSyHnajNWPxiUhIgGBf4PI0T\n' +
'/Jg1ea/aDykxv0rKHnw9/5vYGIsM2st/kR7l5mMecg/2Qa145HsLfMptHo1ZOPWF\n' +
'9gcuttPTegY6aqKPhGthIYX2MwSDMM+X0ri6m0q2JtqjclAjG7yG4CjbtGTt/UlE\n' +
'WMlSZwKBgQDslGeLUnkW0bsV5EG3AKRUyPKz/6DVNuxaIRRhOeWVKV101claqXAT\n' +
'wXOpdKrvkjZbT4AzcNrlGtRl3l7dEVXTu+dN7/ZieJRu7zaStlAQZkIyP9O3DdQ3\n' +
'rIcetQpfrJ1cAqz6Ng0pD0mh77vQ13WG1BBmDFa2A9BuzLoBituf4g==\n' +
'-----END RSA PRIVATE KEY-----',
cert: '-----BEGIN CERTIFICATE-----\n' +
'MIICpDCCAYwCCQCuVLVKVTXnAjANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDEwls\n' +
'b2NhbGhvc3QwHhcNMTUwMjEyMTEzMjU4WhcNMjUwMjA5MTEzMjU4WjAUMRIwEAYD\n' +
'VQQDEwlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDp\n' +
'nlCqHD6hZ+G0SIwcTfYe33ibBMGkB+O3e8+lfwzsMwJPAezXFxe9DiVDevCt4NM5\n' +
'Z2gl4QTLuAzFeofSPDRZ8HH7wgglTr3Gy07JPsVEUO8DXJNFdjbK30rmzpIqR6f9\n' +
'5sx+ZlH7Tc0PhQWwPOTPQV+ByOuReaswXkSHlRdf/71gd5TGWnSAwC379K6ZKaAJ\n' +
'rKzFkuMul3i5fP5f4lXGyTUAKDU2gQ7uVYkL/dYE/swhy7bBWwx3Zd6AtPSBZh9P\n' +
'X+OwgfrhT+ZL9JVcL1CHEyX1h+SjaTHU6JiEPGEezvlElmGs1HGB+LCBJvnQbDg0\n' +
'BBoMjGv8Mk90uGCeljpRAgMBAAEwDQYJKoZIhvcNAQELBQADggEBABXm8GPdY0sc\n' +
'mMUFlgDqFzcevjdGDce0QfboR+M7WDdm512Jz2SbRTgZD/4na42ThODOZz9z1AcM\n' +
'zLgx2ZNZzVhBz0odCU4JVhOCEks/OzSyKeGwjIb4JAY7dh+Kju1+6MNfQJ4r1Hza\n' +
'SVXH0+JlpJDaJ73NQ2JyfqELmJ1mTcptkA/N6rQWhlzycTBSlfogwf9xawgVPATP\n' +
'4AuwgjHl12JI2HVVs1gu65Y3slvaHRCr0B4+Kg1GYNLLcbFcK+NEHrHmPxy9TnTh\n' +
'Zwp1dsNQU+Xkylz8IUANWSLHYZOMtN2e5SKIdwTtl5C8YxveuY8YKb1gDExnMraT\n' +
'VGXQDqPleug=\n' +
'-----END CERTIFICATE-----',
/*
// default iojs cipher set, copied from https://certsimple.com/blog/a-plus-node-js-ssl
ciphers: [
'ECDHE-RSA-AES256-SHA384',
'DHE-RSA-AES256-SHA384',
'ECDHE-RSA-AES256-SHA256',
'DHE-RSA-AES256-SHA256',
'ECDHE-RSA-AES128-SHA256',
'DHE-RSA-AES128-SHA256',
'HIGH',
'!aNULL',
'!eNULL',
'!EXPORT',
'!DES',
'!RC4',
'!MD5',
'!PSK',
'!SRP',
'!CAMELLIA'
].join(':'),
*/
honorCipherOrder: true
};
/**
* Mixes existing values with the default ones.
*
* @param {Object} [opts] TLS options
* @returns {Object} Object with mixed TLS values
*/
function getTLSOptions(opts) {
let result = {};
opts = opts || {};
Object.keys(opts).forEach(key => {
result[key] = opts[key];
});
Object.keys(tlsDefaults).forEach(key => {
if (!(key in result)) {
result[key] = tlsDefaults[key];
}
});
return result;
}

1196
imap-core/test/fixtures/append.eml vendored Normal file

File diff suppressed because it is too large Load diff

24
imap-core/test/fixtures/chunks.js vendored Normal file

File diff suppressed because one or more lines are too long

599
imap-core/test/fixtures/mimetorture.eml vendored Normal file
View file

@ -0,0 +1,599 @@
Subject: Ryan Finnie's MIME Torture Test v1.0
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-qYxqvD9rbH0PNeExagh1"
Message-Id: <1066976914.4721.5.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:28:34 -0700
--=-qYxqvD9rbH0PNeExagh1
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
Welcome to Ryan Finnie's MIME torture test. This message was designed
to introduce a couple of the newer features of MIME-aware MUAs, features
that have come around since the days of the original MIME torture test.
Just to be clear, this message SUPPLEMENTS the original torture test,
not replaces it. The original test is still very much valid these days,
and new MUAs should strive to first pass the original test, then this
one.
By the way, the message/rfc822 parts have Content-Descriptions
containing Futurama quotes. Bonus points if the MUA display these
somewhere.
Have fun!
Ryan Finnie
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: I'll be whatever I wanna do. --Fry
Content-Type: message/rfc822
Subject: plain jane message
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Message-Id: <1066973156.4264.42.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:25:56 -0700
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
Subject: plain jane message
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: text/plain
Message-Id: <1066973156.4264.42.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:25:56 -0700
Content-Transfer-Encoding: 7bit
This is a plain text/plain message. Nothing fancy here...
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Would you kindly shut your noise-hole? --Bender
Content-Type: message/rfc822
Subject: messages inside messages inside...
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-9Brg7LoMERBrIDtMRose"
Message-Id: <1066976111.4263.74.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:15:11 -0700
--=-9Brg7LoMERBrIDtMRose
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
While a message/rfc822 part inside another message/rfc822 part in a
message isn't too strange, 200 iterations of that would be. The MUA
should have some sense when to stop looping through.
--=-9Brg7LoMERBrIDtMRose
Content-Disposition: inline
Content-Description: At the risk of sounding negative, no. --Leela
Content-Type: message/rfc822
Subject: the original message
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-XFYecI7w+0shpolXq8bb"
Message-Id: <1066975745.4263.70.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:09:05 -0700
--=-XFYecI7w+0shpolXq8bb
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
by this point, I should be the 3rd layer deep!
I also have an attachment.
--=-XFYecI7w+0shpolXq8bb
Content-Disposition: attachment; filename=foo.gz
Content-Transfer-Encoding: base64
Content-Type: application/x-gzip; NAME=foo.gz
H4sIAOHBmD8AA4vML1XPyVHISy1LLVJIy8xLUchNVeQCAHbe764WAAAA
--=-XFYecI7w+0shpolXq8bb--
--=-9Brg7LoMERBrIDtMRose--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Dirt doesn't need luck! --Professor
Content-Type: message/rfc822
Subject: this message JUST contains an attachment
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Disposition: attachment; filename=blah.gz
Content-Transfer-Encoding: base64
Content-Description: Attachment has identical content to above foo.gz
Message-Id: <1066974048.4264.62.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:40:49 -0700
Content-Type: application/x-gzip; NAME=blah.gz
SubjectthismessageJUSTcontainsanattachmentFromRyanFinnierfinniedomaindomTobo
bdomaindomContentDispositionattachmentfilenameAblahgzContentTypeapplication/
xgzipnameAblahgzContentTransferEncodingbase64ContentDescriptionAttachmenthas
identicalcontenttoabovefoogzMessageId1066974048426462camellocalhostMimeVersi
on10Date23Oct20032240490700H4sIAOHBmD8AA4vML1XPyVHISy1LLVJIy8xLUchNVeQCAHbe7
64WA
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Hold still, I don't have good depth perception! --Leela
Content-Type: message/rfc822
Subject: Attachment filename vs. name
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-1066975756jd02"
Message-Id: <1066975756.4263.70.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:09:16 -0700
--=-1066975756jd02
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
In this message's attachment, the Content-Disposition has a
filename of blah1.gz, while the Content-Type has a name of
blah2.gz. What should be done? Well, since this is an attachment
(as indicated in the Content-Disposition), the MUA should
suggest a filename of blah1.gz. The MUA *COULD* find a way to
represent the name of blah2.gz somewhere else, it's not needed.
--=-1066975756jd02
Content-Disposition: attachment; filename=blah1.gz
Content-Transfer-Encoding: base64
Content-Description: filename is blah1.gz, name is blah2.gz
Content-Type: application/x-gzip; NAME=blah2.gz
H4sIAOHBmD8AA4vML1XPyVHISy1LLVJIy8xLUchNVeQCAHbe764WAAAA
--=-1066975756jd02--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Hello little man. I WILL DESTROY YOU! --Moro
Content-Type: message/rfc822
Subject: No filename? No problem!
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-1066975756jd03"
Message-Id: <1066975761.4263.70.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:09:21 -0700
--=-1066975756jd03
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
When searching for a suitable name to suggest for a filename,
the MUA should probably follow this order. First, look for
Content-Disposition's filename attribute. If that is missing,
look for Content-Type's file attribute. If that is also missing,
I would recomment taking the Content-Description, stripping off
any characters that cannot be used in a filename, and suggesting
that.
If none of those fields are available, the MUA could just make
up a random filename. SOMETHING is better than nothing.
--=-1066975756jd03
Content-Disposition: attachment
Content-Transfer-Encoding: base64
Content-Description: I'm getting sick of witty things to say
Content-Type: application/x-gzip
H4sIAOHBmD8AA4vML1XPyVHISy1LLVJIy8xLUchNVeQCAHbe764WAAAA
--=-1066975756jd03--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Friends! Help! A guinea pig tricked me! --Zoidberg
Content-Type: message/rfc822
Subject: html and text, both inline
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-ZCKMfHzvHMyK1iBu4kff"
Message-Id: <1066974044.4264.62.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:40:45 -0700
--=-ZCKMfHzvHMyK1iBu4kff
Content-Type: text/html; CHARSET=utf-8
Content-Transfer-Encoding: 8bit
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 TRANSITIONAL//EN">
<HTML>
<HEAD>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; CHARSET=UTF-8">
<META NAME="GENERATOR" CONTENT="GtkHTML/1.1.10">
</HEAD>
<BODY>
<FONT COLOR="#f8cc00">This is the HTML part.</FONT><BR>
It should be displayed inline.
</BODY>
</HTML>
--=-ZCKMfHzvHMyK1iBu4kff
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
This is the text part.
It should ALSO be displayed inline.
--=-ZCKMfHzvHMyK1iBu4kff--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Smeesh! --Amy
Content-Type: message/rfc822
Subject: text and text, both inline
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-pNc4wtlOIxs8RcX7H/AK"
Message-Id: <1066974089.4265.64.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:41:29 -0700
--=-pNc4wtlOIxs8RcX7H/AK
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
This is the first text part.
It should be displayed inline.
--=-pNc4wtlOIxs8RcX7H/AK
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
This is the second text part.
It should also be displayed inline.
--=-pNc4wtlOIxs8RcX7H/AK--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: That's not a cigar. Uh... and it's not mine. --Hermes
Content-Type: message/rfc822
Subject: HTML and... HTML?
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-zxh/IezwzZITiphpcbJZ"
Message-Id: <1066973957.4263.59.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:39:17 -0700
--=-zxh/IezwzZITiphpcbJZ
Content-Type: text/html; CHARSET=utf-8
Content-Transfer-Encoding: 8bit
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 TRANSITIONAL//EN">
<HTML>
<HEAD>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; CHARSET=UTF-8">
<META NAME="GENERATOR" CONTENT="GtkHTML/1.1.10">
</HEAD>
<BODY>
<B>Bold!!!</B><BR>
<BR>
What do we have here... This message is an HTML message. Also attached
is an HTML FILE. Both of these are in a multipart/mixed part.<BR>
<BR>
Now, the first HTML part (what you're reading now) should be displayed
if the MUA is HTML-capable. If it is not, the MUA could possibly offer
this part up as an attachment to download, seeing as how no plaintext
part is offered as an alternative.<BR>
<BR>
However, the second HTML part is listed with a disposition as
attachment. Therefore, it should be offered as an attachment, not
displayed inline.
</BODY>
</HTML>
--=-zxh/IezwzZITiphpcbJZ
Content-Disposition: attachment; filename=htmlfile.html
Content-Type: text/html; NAME=htmlfile.html; CHARSET=UTF-8
Content-Transfer-Encoding: 8bit
<html>
<head><title>This is an Attachment</title></head>
<body>
<p>The title says it all...</p>
</body>
</html>
--=-zxh/IezwzZITiphpcbJZ--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: The spirit is willing, but the flesh is spongy, and
bruised. --Zapp
Content-Type: message/rfc822
Subject: smiley!
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/signed; micalg=pgp-sha1; protocol="application/pgp-signature"; boundary="=-vH3FQO9a8icUn1ROCoAi"
Message-Id: <1066972996.4264.39.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:23:16 -0700
--=-vH3FQO9a8icUn1ROCoAi
Content-Type: multipart/mixed; boundary="=-CgV5jm9HAY9VbUlAuneA"
--=-CgV5jm9HAY9VbUlAuneA
Content-Type: multipart/related; type="multipart/alternative";
boundary="=-GpwozF9CQ7NdF+fd+vMG"
--=-GpwozF9CQ7NdF+fd+vMG
Content-Type: multipart/alternative; boundary="=-dHujWM/Xizz57x/JOmDF"
--=-dHujWM/Xizz57x/JOmDF
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
If this sentence is red, you are viewing the HTML part.
Wow, what a complicated message. This message is laid out as so:
multipart/signed
| multipart/mixed
| | mutipart/related
| | | multipart/alternative
| | | | text/plain
| | | | text/html
| | | image/png (smiley)
| | image/gif (dot)
| application/pgp-signature
:)
A smiley face should be embedded into the HTML part above (if you are
viewing the HTML part), while the red square dot should be attached, not
displayed inline. Additionally, this whole message is PGP signed.
This message introduces a few tricks that the MUA should cope with.=20
First of all, the related / alternative combination doesn't make much
sense. Here's the current setup in pseudo-code:
relationship between (alternative: HTML or text part) and PNG part
Why would the text part be related in anyway to the PNG? Instead, the
correct and more logical way to do things would be:
alternative: (relationship between HTML and PNG parts) or text part
However, many MUAs compose a message using the first method, so this
should be taken care of when parsing the message.
Additionally, notice that the inline image has a disposition of
"attachment". Despite this being in there, the smiley should be
embedded inline in the HTML part, not offered as an attachment.=20
Conversely, the GIF image should be offered as an attachment, not
displayed inline.
If the MUA is not PGP capable, at the very least it should recognize
multipart/signed the same as multipart/mixed, and offer the
application/pgp-signature part as an attachment.
--=-dHujWM/Xizz57x/JOmDF
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 TRANSITIONAL//EN">
<HTML>
<HEAD>
<META HTTP-EQUIV=3D"Content-Type" CONTENT=3D"text/html; CHARSET=3DUTF-8">
<META NAME=3D"GENERATOR" CONTENT=3D"GtkHTML/1.1.10">
</HEAD>
<BODY>
<FONT COLOR=3D"#f80000">If this sentence is red, you are viewing the HTML p=
art.</FONT><BR>
Wow, what a complicated message.  This message is laid out as so:<BR>
<BR>
multipart/signed<BR>
| multipart/mixed<BR>
| | mutipart/related<BR>
| | | multipart/alternative<BR>
| | | | text/plain<BR>
| | | | text/html<BR>
| | | image/png (smiley)<BR>
| | image/gif (dot)<BR>
| application/pgp-signature<BR>
<BR>
<IMG SRC=3D"cid:1066971953.4232.15.camel@localhost" ALIGN=3D"bottom" ALT=3D=
":)" BORDER=3D"0"><BR>
<BR>
A smiley face should be embedded into the HTML part above (if you are viewi=
ng the HTML part), while the red square dot should be attached, not display=
ed inline.  Additionally, this whole message is PGP signed.<BR>
<BR>
This message introduces a few tricks that the MUA should cope with.  F=
irst of all, the related / alternative combination doesn't make much sense.=
  Here's the current setup in pseudo-code:<BR>
<BR>
<I>relationship between (alternative: HTML or text part) and PNG part</I><B=
R>
<BR>
Why would the text part be related in anyway to the PNG?  Instead, the=
correct and more logical way to do things would be:<BR>
<BR>
alternative: (relationship between HTML and PNG parts) or text part<BR>
<BR>
However, many MUAs compose a message using the first method, so this should=
be taken care of when parsing the message.<BR>
<BR>
Additionally, notice that the inline image has a disposition of "attac=
hment".  Despite this being in there, the smiley should be embedd=
ed inline in the HTML part, not offered as an attachment.  Conversely,=
the GIF image should be offered as an attachment, not displayed inline.<BR=
>
<BR>
If the MUA is not PGP capable, at the very least it should recognize multip=
art/signed the same as multipart/mixed, and offer the application/pgp-signa=
ture part as an attachment.
</BODY>
</HTML>
--=-dHujWM/Xizz57x/JOmDF--
--=-GpwozF9CQ7NdF+fd+vMG
Content-ID: <1066971953.4232.15.camel@localhost>
Content-Disposition: attachment; filename=smiley-3.png
Content-Type: image/png; name=smiley-3.png
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAC+klEQVR42n2TbUjVdxTHP/+H69Xd
a2VWlFe69rzthZJUoxeNOWoFGxEhYRRFmZSVW2u9ab2KejWE1qDNBkEQhS82VoiaZkVPmoWaKNM5
mA+opbd771//997//T/+epHBarEPHA6Hc84XDnwP/JcwcBS4AVgzcR04ONN7C+md+pcPCz44dPLA
arZs/gg1UABuGkvvp7X1Iad+itE/YtUAle8TuH26sujzqq/LkJQsnOQQVmIASVJQMhehZORiJwc5
d76FH2pf3gY2Aigzy7+eObqmtOqbXbjGGHZqCM+eQpJ9AHhWFCc5CAjWf1KAkppc+qg3vRCol4Fw
0aqcisOVW3HTE7hmBElSKD/5GFkNMhH1KDvegST78CwNSfZxeM88VuYrh4CwAuxqvxL6MnPuWiy9
H1kNUPH9fZofDKPpHn8/z+Z6Yw8JK5stX5VhRO6h+OfiV3WaHxtPVKAwmF+KqXUDMkgqZ0+UoKcE
P57/GXOqh46ODqrPXUQfufb6YOGxJOQD2CaHQnnlAJ4zDXggHBYvK6ap6Rau+RIz1k7djd+YHrqM
pXUC4KQnWTRPAdiuRqNRkFQG/omRNJOsKVQw408xtS4QDsI10AaqEY6O8Fzq70fJy3XI8gsA5HTa
rBdOkvwFKj39EWrr/sJzEnj29OvsphGugfBsLlwbZnjcYN36LxiLuADtMtCUetFAcE4ee8s+pbHV
YtOemwhHx3MSaPEY3X9OUnqsk5a2OMeP7KC3t4u+3gRALUC4cEW2eN62Q4ze3SAiz74TDxvOiI+X
BcTsoCoyfJKYn6OKmrMbxGRnlXhyJSSqv80Vq0KSAFa+ceKl0wcK9lfsW42TGsE/pxhfcDmKfz6e
FUPg4iRH6Ov6g9EJh1t341xusWuAyn9b+c7BrbklJ8oDZGTOQpL9ePY08SmDpCEwbcHwuE370yku
Nlj3gM/e90yXliyU9+8sCVJYlEUgU8IwBZruMThm83uzxsAYV4Hd/A9h4BjQBthAFOgDLgDF7w6/
ArI6YJ0eTQeGAAAAAElFTkSuQmCC
--=-GpwozF9CQ7NdF+fd+vMG--
--=-CgV5jm9HAY9VbUlAuneA
Content-Disposition: attachment; filename=dot.gif
Content-Type: image/gif; name=dot.gif
Content-Transfer-Encoding: base64
R0lGODdhCgAKAKEAAAAAANUAAP///8PDwywAAAAACgAKAEACHZSPMssLKoIMYLyR1I2z3sZsE2VB
owcBqlqurloAADs=
--=-CgV5jm9HAY9VbUlAuneA--
--=-vH3FQO9a8icUn1ROCoAi
Content-Type: application/pgp-signature; name=signature.asc
Content-Description: This is a digitally signed message part
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.2.2 (GNU/Linux)
iD8DBQA/mLdEKZYQqSA+yiURAjAnAJ90G22jbX/Broy0F541R0UUbsb6zgCeJn0d
02Vq9Sv6aXE+YM0lRn3jZDc=
=uwCM
-----END PGP SIGNATURE-----
--=-vH3FQO9a8icUn1ROCoAi--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Kittens give Morbo gas. --Morbo
Content-Type: message/rfc822
Subject: the PROPER way to do alternative/related
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/alternative; type="multipart/alternative"; boundary="=-tyGlQ9JvB5uvPWzozI+y"
Message-Id: <1066973557.4265.51.camel@localhost>
Mime-Version: 1.0
X-Mailer: Not Evolution
Date: 23 Oct 2003 22:32:37 -0700
--=-tyGlQ9JvB5uvPWzozI+y
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
If this sentence is green, you're viewing the HTML part.
Now, this is the way that all MUAs SHOULD treat this kind of situation.
The layout is like so:
multipart/alternative
| text/plain
| multipart/related
| | text/html
| | image/gif
See? The GIF (which by the way should be inline towards the top of this
message) is related to the HTML, and that whole block is an alternative
to a text/plain part. This is the opposite of the way shown in the
previous email.
Also, the embedded image here does not have a filename. As mentioned
above, the MUA should suggest something as a filename, even here (the
user may want to save the embedded image, so a filename would be
helpful). In this case, I would recommend appending the random text
to be suggested to the user with the part's subtype, in this case
something like c20vsidlkvm.gif.
--=-tyGlQ9JvB5uvPWzozI+y
Content-Type: multipart/related; boundary="=-bFkxH1S3HVGcxi+o/5jG"
--=-bFkxH1S3HVGcxi+o/5jG
Content-Type: text/html; CHARSET=utf-8
Content-Transfer-Encoding: 8bit
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 TRANSITIONAL//EN">
<HTML>
<HEAD>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; CHARSET=UTF-8">
<META NAME="GENERATOR" CONTENT="GtkHTML/1.1.10">
</HEAD>
<BODY>
<FONT COLOR="#00fc00">If this sentence is green, you're viewing the HTML part.</FONT><BR>
<IMG SRC="cid:1066973340.4232.46.camel@localhost" ALIGN="top" ALT="" BORDER="0"><BR>
Now, this is the way that all MUAs <B>SHOULD</B> treat this kind of situation.  The layout is like so:<BR>
<BR>
multipart/alternative<BR>
| text/plain<BR>
| multipart/related<BR>
| | text/html<BR>
| | image/gif<BR>
<BR>
See?  The GIF (which by the way should be inline towards the top of this message) is related to the HTML, and that whole block is an alternative to a text/plain part.  This is the opposite of the way shown in the previous email.<BR>
<BR>
Also, the embedded image here does not have a filename.  As mentioned above, the MUA should suggest something as a filename, even here (the user may want to save the embedded image, so a filename would be helpful).  In this case, I would recommend appending the random text to be suggested to the user with the part's subtype, in this case something like c20vsidlkvm.gif.
</BODY>
</HTML>
--=-bFkxH1S3HVGcxi+o/5jG
Content-ID: <1066973340.4232.46.camel@localhost>
Content-Transfer-Encoding: base64
Content-Type: image/gif
R0lGODlhBQALAPIAAKIA/64A/8ZL/////8BS/2QDANZ//wAAACH5BAEAAAMALAAAAAAFAAsAAAMY
OBIytsIYEoiEl0lqFWgKM4zkUJzjWQwJADs=
--=-bFkxH1S3HVGcxi+o/5jG--
--=-tyGlQ9JvB5uvPWzozI+y--
--=-qYxqvD9rbH0PNeExagh1--

2180
imap-core/test/fixtures/mimetorture.js vendored Normal file

File diff suppressed because one or more lines are too long

1411
imap-core/test/fixtures/mimetorture.json vendored Normal file

File diff suppressed because one or more lines are too long

104
imap-core/test/fixtures/mimetree.js vendored Normal file
View file

@ -0,0 +1,104 @@
'use strict';
module.exports.rfc822 = '' +
'Subject: test\ r\ n ' +
'Content-type: multipart/mixed; boundary=abc\r\n' +
'\r\n' +
'--abc\r\n' +
'Content-Type: text/plain\r\n' +
'\r\n' +
'Hello world!\r\n' +
'--abc\r\n' +
'Content-Type: image/png\r\n' +
'\r\n' +
'BinaryContent\r\n' +
'--abc--\r\n';
module.exports.mimetree = {
childNodes: [{
header: ['Content-Type: text/plain'],
parsedHeader: {
'content-type': {
value: 'text/plain',
type: 'text',
subtype: 'plain',
params: {}
}
},
body: 'Hello world!',
multipart: false,
boundary: false,
lineCount: 1,
size: 12
}, {
header: ['Content-Type: image/png'],
parsedHeader: {
'content-type': {
value: 'image/png',
type: 'image',
subtype: 'png',
params: {}
}
},
body: 'BinaryContent',
multipart: false,
boundary: false,
lineCount: 1,
size: 13
}],
header: ['Subject: test',
'Content-type: multipart/mixed; boundary=abc'
],
parsedHeader: {
'content-type': {
value: 'multipart/mixed',
type: 'multipart',
subtype: 'mixed',
params: {
boundary: 'abc'
},
hasParams: true
},
subject: 'test'
},
body: '',
multipart: 'mixed',
boundary: 'abc',
lineCount: 1,
size: 0,
text: '--abc\r\nHello world!\r\n--abc\r\nBinaryContent\r\n--abc--\r\n'
};
module.exports.bodystructure = [
['text',
'plain',
null,
null,
null,
'7bit',
12,
1,
null,
null,
null,
null
],
['image',
'png',
null,
null,
null,
'7bit',
13,
null,
null,
null,
null
],
'mixed', ['boundary', 'abc'],
null,
null,
null
];
module.exports.command = '* FETCH (BODYSTRUCTURE (("text" "plain" NIL NIL NIL "7bit" 12 1 NIL NIL NIL NIL) ("image" "png" NIL NIL NIL "7bit" 13 NIL NIL NIL NIL) "mixed" ("boundary" "abc") NIL NIL NIL))';

582
imap-core/test/fixtures/nodemailer.eml vendored Normal file
View file

@ -0,0 +1,582 @@
Content-Type: multipart/mixed;
boundary="----sinikael-?=_1-14507714776260.4520441491622478"
From: =?UTF-8?Q?Sender_Name_=F0=9F=91=BB?= <sender@example.com>
To: =?UTF-8?Q?Receiver_Name_=F0=9F=91=A5?= <receiver@example.com>
Subject: Nodemailer is unicode friendly =?UTF-8?Q?=E2=9C=94?=
X-Laziness-Level: 1000
X-Mailer: nodemailer (1.10.0; +http://www.nodemailer.com; Stub/1.0.0)
Date: Tue, 22 Dec 2015 08:04:37 +0000
Message-Id: <1450771477632-949c9a1f-bddfb7f5-b8b49a6d@example.com>
MIME-Version: 1.0
------sinikael-?=_1-14507714776260.4520441491622478
Content-Type: multipart/alternative;
boundary="----sinikael-?=_2-14507714776260.4520441491622478"
------sinikael-?=_2-14507714776260.4520441491622478
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
Hello to myself!
------sinikael-?=_2-14507714776260.4520441491622478
Content-Type: text/watch-html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<b>Hello</b> to myself =F0=9F=8E=A9
------sinikael-?=_2-14507714776260.4520441491622478
Content-Type: multipart/related; type="text/html";
boundary="----sinikael-?=_5-14507714776260.4520441491622478"
------sinikael-?=_5-14507714776260.4520441491622478
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<p><b>Hello</b> to myself <img src=3D"cid:note@example.com"/> =
=F0=9F=91=A3</p><p>Here's a nyan cat for you as an embedded =
attachment:<br/><img src=3D"cid:nyan@example.=
com"/></p>
------sinikael-?=_5-14507714776260.4520441491622478
Content-Type: image/png; name=image.png
Content-Id: <note@example.com>
Content-Disposition: attachment; filename=image.png
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD///+l2Z/dAAAAM0lE
QVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDeNGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQ
AAAAAElFTkSuQmCC
------sinikael-?=_5-14507714776260.4520441491622478
Content-Type: image/gif; name="nyan cat =?UTF-8?Q?=E2=9C=94=2Egif?="
Content-Id: <nyan@example.com>
Content-Disposition: attachment;
filename*0*=utf-8''nyan%20cat%20%E2%9C%94.gif
Content-Transfer-Encoding: base64
R0lGODlh9AFeAaIHAAAAAP+Z/5mZmf/Mmf8zmf+Zmf///wAAACH/C05FVFNDQVBFMi4wAwEAAAAh
+QQJBwAHACwAAAAA9AFeAUAD/3i63P4wykmrvTjrzbv/YCiOZGmeaKqubOu+cCzPdG3fQK7vfO//
wKBwSCwaj8ikcsks3p7QqFTSrFqv2Kx2yxVOv+AwiTgom8/otHrNbrvf8Lh8Tq/b7/j4UMzv04x5
eAGDhIWGh4iJiouMjY6PkJGSk4+BdkZ+mZocgJZ1lKChoqOkpaaGnnSYm6ytVEWpZaeztAEEt7i5
urW6vQS1wI2xq67Fxp2pwcqgvs28zbjL0oXDTsbXrchtz9C509/g4eKLb8TY533abNzdt+Pv8PG1
5dbo9mHqa/L7/IhZs+280aJH5IKWewhfkXnTr6EyIwIiSpxIsWJFIw4TEcxisf+jx4nmErLKpyaj
yVMQP6qkiPEkNTdcVsqUGHJKlywjSKZxqSzgL1opZwpVSUTeRiFDk36sKeUmlpywGPIE5rNWUKVY
IxaNdzRI1q9a64EhArYjTgs60UxdK+lq2bcgnVaBS9ci0yhk64a9YjCqG7aAHbnVC1buXMJ670LR
gtiwDzjyAgYWXASx5cuYMy8V+4UxYcc9IMeTPJnRYM2oU6tWqlhTTLpcRJee5vPW6dW4c+u23DrT
a7hpz0Cq/XM2qNuZDShfzrx53t11m0tn/rxubz+/3wY3M7y28VDIMU8fXx362/HTywPnLJIB6CWy
v/MLf9nz7iFC7Vtn317BeyX/8cknD328cQQdfjPpBxt//UGlRYACEjcLgRIVYOGFGGaooYbmdZja
hiCGqOF1DXYQm1QCEiIhSpXNJOKLHHoo42Uw1pghiSWqsB0d3VWVYgAURmRjjfkhmFiL66ln0ZBD
4pgjCjvO0SNpKQYpAJMvFonUZ0hq16VHWNbo5JMmRBkLdz9a9WVq/7XJxJEFkTmDmzmcaeedeFbj
1Yx83segnHjRmeeghBYK05Z9JqramICWYCRudEYq6aSUVupUo9ghCqmlnHbq6aecYpqOpgs+hdaa
XzHaGaqp/umAlSupKqpNpCbJ16lKZiVrU6zq6moDsBL166yjglqmm8Qq1Oax/7cm6+yz0EYr7bTU
Vmvttdhmq+223Hbr7bfghivuuOSWa+656Kar7rrstuvuu/DGK68roNZr771nzesuvvz2668X+oa7
kKEEF2zwwQh31UPA1Q6c8MMQRyxxHnsw/KSZdqapMVcI72oxDn4dvPHI4iTs8cc1YAwhyYasCFA7
I+tZMcojhbwOLS6zrPM+Cv9AczY264OzdzsXzXEbJ/8cQ5RGy/fPKTmb0rMPfZmqdAZMN23c06ZE
XcrUC+Pa7NVVD7Gy1u8Eu9lWU4G9BJwzX5012vuo7VFLbR+qYFlJ64ivg2ajSLctVLKYa2Zsv+P2
Dn7Gic/fIsw9uI8T9opZ4v/jLK5D43GvWquXY08g+eBpW24evoqyNGzKBsL93tnfFP6j3anXrlvf
KewN+n+w0wbzxrTbLjybq/ORHd9b9E56y7UFP/zz0O/VeaC6+/qg4MsvQpzz0XdfO+6sV4/V6NoT
TTf3WKEn3eHeq+/co6VOD7T4rAVd0iNeG42+Uu5TB7/3E+nfcthnPfn1B1/Ky15bTFcgLJzuc2Zp
3X4c960T/UVj+QMPAysUJhABEIAdfBH4GmXBbWDQfKWwUgg39EHvrTBEI7SWyrBXPsoJSIUvxJCW
9uQ6Hu4OghTJ4YZi2DD7CQJ/KHTaBoUkRAvtEAiNWaJMKNTEGxVvXDNMhgL/TSPFCf7LMT0EAtn8
Y8RCbZGLBGzgFw0TRp/BK1ITi6McB/C/FtoxLhRUGhznyMeH1fGOdyTitf6omTUa8pCIpNMY3QPE
VlmNAvuTHsCuEUlJBqFsjUyKIOVFSEeGTlmZHMomX1BJATipkqN8Y72Y9R8ZKtJRj1wk4DzFyve4
Elmw/KQsd8nLXvryl8AMpjCHScxiGvOYyEymMpfJzGY685nQjKY0p0nNalqTmonMpjZxec0cbfOb
4LxJNy8WznKaswnj9OY518nOPKZzEw7rozznSU+ZufGdtBJCPffJz37KwYD4DF8Q/EnQghYUoAEd
QxkJdcaGvuRgqZRmFj3h/9CKmuyKCX3VQgdVUYde1J0ZFZs+E9bRhn4UoSGNwERvRrcMikJ2KbLn
JVOKtY3yaHJJLAVMBSRTMdIUAysVWktzSoqdyqen9/xpBYJ6v6HasGu/0xhSqaZUkQ6UhkUlakm3
OgjN5aCqVgVCAifhUq521KsAAOtSbToAdkTVrHBFq1ohyVa3dgOueA2AXOcKyqteMK+lu8LLnvo1
vaGUkbEcJvkA+w2u6VSrotjrWhMrzMUyVhqOzSphSSFZulI2mJa97CRQWQS2oLWUEfVWaEUbCdJi
7iSn7WKsMKrKeAqVtRpM4xODYFrDOtCLkxwL5EKw2p1tdhSlTNAQjOJbH/+uJrUe4Ncs/WpC0h1X
FMmd4nKPxobsahek1LPXdMWK1aZdN7edrM924YFWzgXXc+KNXF2Xd97jyNZWQGAu0u6L35k+LpT1
0yUEiovbRnj3h58aHnRNlN7xffYBBC7wIg6MPHspmLYw0K0mH6xR2zZVwqPlL2pQJ7wFc0KC8Xtd
eWP31tmJGJAwFhZ4F4Pi/jpmrMEw6ncoHOMWmngD9Cugiv/6Dh1v7cU9TjIeDytQK0SxTTimSour
hGQlK/nHJzheYZK34oZur8pWDnOK/ZvPGnsSC1G2bvPALOY2n5nMvAqyKLlM5K1+WcNuzjNw4Rze
39q4CmnGqU94rOdC0wT/w2X2M4LJW+caGpllhLaIAJWDZ+FN2gCVVu6MXbOFPTO6uo0oK6TZnJRL
Z7p2pm5wgJlsvE6P+dMsDTVkdRbpiqQawM+7tXP/TFVKuprXoeky8+pLslpTRNdQjDGyf/BkVqMD
gcK+rLGX7OQD4dqUZq6ws88B7UZLeNqHVnRuVG3JJjT7vSXqNqhBDCRSz1ncm9r13bK95U1jq4Sx
TpGoF4jnKhbA0Obx94WwbA9831bfsw5xv6sIcOgI3EIEfzad1x2hhPOb3FdieMN18/ACRBxQTN0J
EonNFhw2cbc+OLe8hbzyID7843IKuVpG/mjAmFyIKO+BypMNbB7IpOMw/yeTzIVD8ykfeeEn13TL
38zzRTN7JUBHtGrZKocpGX02N89hzn3OpVPPW7dRt7e5hv4JuIK73O10N7bFri+y39SsZ1972pHQ
xl7LjeoZM7valT531Eq9W3s0qODngPGN3+7v6wr84BffXJ0b/nuIV5fiGU/5NBT+8c+NfLomX/nO
0/HamM882891+Vf3/fSolxQvS9/z1Lv+9V1YPeg9Dfva257DKGO902/P+94fQfZLZzk6w/r01uuA
22o/pbuDjsXZf1fAEF6+5m3g99EfwLXbXqTu1wb9Dm//ItNvcvA3bH3so1uxzp8t7hH7fdVZ/7/t
d7+zzc9n9Bsrl60sIv83x7uEbsZXocuifwEIgFbgf6uEf7YkgPlHgFXAVw74gBAYgRI4gRRYgRZ4
gRiYgRq4gRzYgR74gSAYgiI4giRYgiZ4giiYgiq4gizYgi74gjAYgzI4gzRYgzZ4g8bkezp4LzgY
XTv4g7TUgycGhERIKUI4hEWYhPt3hJikhE5oGEyoAU84hY4RhTVFhVjIBVa4AB7meV74hRRzfi7Y
hWBYhma4ODVIhme4hmxYBtlHgmrYhnIIhm84gnE4h3hYeXUogneYh34oeHsYgn34h4TYT4EYgW4n
JewWMx0TfuOUiFW3iBtzUmL4gJAYB5I4iY34fj91iYGWiS5BifUHgZ7/GG2g2FsQ5YgGOIh5d4o8
tYmHGFCl6G2uOBmi6FMVmIhqRnKSUHO2mArMV0y6KGi+2ItX9x1TFTa5iHefWHG8GAnFCBjJyAMX
OIxOFY1W5wuM6AnBSEzWiDb7RlbHaBzTuAPVyIymuDHhaIx3pYncqIrX9I1as47QOI6zUY7HZ4Hy
aF4WJ47tKFXACI/WFGHsiI21eFSNl1QTSJD1+IwHiZD7xYk0xZDZCA0P2TSdtZDzNVgGeZGlkZES
SJFF948eyTIgiYgbCTX9WJLkmJB2p5F9aFcWyZIm6ZLKSIEiSZOEkFmjQI+QcJIDli/CmJI6CQk8
+VIrGQlAGX3rB3yB/0OLRTlhWMCR9kgJS+l9BXhMOVmURxkKPlkJNkmNxNd/WkmUUWlgU6mSDgmW
ETl/QumNZnmWUilYatmRwhCW5jiWSoBMW8lu9JdfaxFbXvd1sbh5cVmSf/kDqNhdv2Z8XzVGfQli
iekDi7kGWlZvhYkukflt0vdaJiGY8Udto/gxm1lgk9kDlakGl8l0CvkzpYlbp8kDqWl5jbl7N6lH
h+mRsbkDs4kGqyl8rUkzr8lau6kDvXkGv+lgEklKwwUCw1kaa0kZg4mZgMleeLk5h7ecLiBd8hWT
9GWXaBSa5Mdb3GWZe8d3o/kE3ElcuVk00YmW08maslmeqnmez1eJ6v/ZnB/wnJPxnuGZftpWnYpz
nTngXukJMjzYnU9JcfMInnMpnu9GntbZlgAaoAdKffrpg965iyRpCnHnmeDQXtmZmVCSoQy2ocQ4
k5UTn8A5nxPKmCzaoreZaMVHe3uplzzQjH5pn7ZZKRemnS0Qo+DXfcDSng8Zd/eZYCUmkPzHdXWX
BE0IawdHk0iqfvXyoySaZRAablk5WQuab1TKoxbqKViKnwg6fqvWgDi6AzoqmWJKnUpqO92oUluK
djfqpSMFlRdZpTJ2pUsKpCwgpPKnpnhKXWDKknzKfaBSphcqfubWdbyTjlLWoVQmqKGXOnMalPDW
o13QppqlommSqJf/ejlMqqDV9qQ34ak9WZWTIaqjqkZmiqGbOqaGoapISak39KavaqC4KFyzCqc3
JqkyuQvAo6u7Om6lyp6/Kp9OYateyaqB4arHaqMzGmdXsHO1KqxDg6tKZKnT6iGZioTLOp5opq3X
2A7S+q2YmqwaSm9pegXOyjJ3VqfqmmThyn7jGqHlqqdnNK8VWq95dq9cWJvACmjmCo5r5q0A22MC
S0ZytnVMEK8k469ourAb17DXR7DMCh8H26CDZqwW2z0Ym5zkCq8dy48fq7AhG0jseqLuWrJWILEj
Q7FNt7KjirFRehiOCQBR9pWVSq8rsWwpp2yTprI4u6ZJQK05Kqk+/5urKhu0RQu0uSG0joeqeelr
D4ueUvphjNC03Sq1H0G1TgpIYss4kBqrxfKy+mqoU6oIXrtjIHtsUfuvfFK22Gm1+Yi1aguxS8uv
hfC2Rwe2HmG3BUq0AmS0LbsCJLu2WytyjgC4WBe3ATS3FTs8hAsA2PqS9KKxMtq3DJoIkFsacXe5
PUa6Z9uoBXcvMps9H7q36lW5Wcu4wXlAqnuyHdW6+UqqsOu6squ5DaJuh2qaksulpzqiNdqnxWt6
vku79rK6y4O712ptu5u7MDu77QG8bSu8T4u8Omu8Q5ukj4q3jyl0teu3jAW9yRtvNauo3au81bot
Bse1zuig/iBF/v9ms2URdlkKchMXvMYRuoWQdS+Ev2Chv2irgCZrvoABwDtpvxpHwFhhwKhLLfHr
uD/CwIMgwCsEwVkhwb06ddejwGyBwe2GdDjHwUrhwdabLRU8c2lCwhocQiicwi+XuCLRwkT3wkkp
nRh3vzM8FCq8vNqCw2iiw/6ZCDHcQT8MxDUMqPCLjiJMwoHVww9spevLqXeLxYWrEkH8vobJij27
wy6RxGHCt1ustGa7sz/XxPuLwI17BxXpDC5mwlqntVWLxllMq9/7EV0slu+yj24rxidBxlhixph7
ulfcuWnMx2x8wM0HxkwryCZByExiyJl7x3qMyR3Rx1e7L1D8uYj/IMXjQMlDYsmIfLwFu8dg0sgT
LC6ADLqSnBGkbCOmLL6HrMZQx8ofzC6zCMoNhb46aMva98mWgFfA7HvCDJnEHAjGPLx2envJTDa9
7L+/7Mxyt4PRfHeQrEVwZ81FmM24uc0UpXfby77IjMfjq81fWjDNXM6Eic3onFa1BWWFWE+Cy8FH
y79uUs/2TLdLbBc2/MT7zM/zdM8QnM8xJygELU8GTcAITb4DvdB81ND4+9DkFNESLUcUbbMWrU4Y
ndETs9Er29F6i8rEk4UoPXdOadIjltIuzU4rrcqL8tI0XU4xrcknXdM6nU03Pbait9NAbUg9vcg/
HdRG3S9Dnccz/33UTG2i4ZzIhdTUUn2AskSvB4G0Z5zJfnwMyXdFxZnOQ1m5hqx8lkrSTLmlZA2h
Zg0tVv2WfQXV77rL84PWXt2ZbTwvbd2UDqvWAV2i3prW/ky8rTzMYm3HZFmocF29Qsxpf13XZd3X
X5zYykmk+FrY5rzCvtHV5WfXjlzVgT2ohOpZfO3EZ/rZz7zVosPZg63M98eAoHFLA9ikUBqPVC3b
4uTGVYiAwzeQtW2qr43bUKjbTLCKn1JLv03Br+Taw03brW3bsQfccmHcy83bze3buY3cS6isXbqF
3N3d3v3d4B3e4j3e5F3e5n3e6J3e6r3e7N3e7v3e8B3f8j3f9Ctd3/Z93/id3/q93/zd3/793wAe
4AI+4ARe4AZ+4Aie4Aq+4Aze4A7u4AkAACH5BAkHAAcALAAAAAD0AV4BQAP/eLrc/jDKSau9OOvN
u/9gKI5kaZ5oqq5s675wLM90bd9Aru987//AoHBILBqPyKRyySzentCoVNKsWq/YrHbLFU6/4DCJ
OCibz+i0es1uu9/wuHxOr9vv+PhQzO/TjHl4AYOEhYaHiImKi4yNjo+QkZKTj4F2Rn6ZmhyAlnWU
oKGio6SlpoaedJibrK1URallp7O0AQS3uLm6tbq9BLXAjbGrrsXGnanByqC+zbzNuMvShcNOxtet
yG3P0LnT3+Dh4otvxNjnfdps3N234++0XPCQ5dZjWejo6mvz/f6i8hq180arHhETWvKd26fmn8OH
AYYImEixosWLGCcagWdw/48FIxlDihRgTmEUhmkgqpwncaTLixvfdfTyscjLmyTtmTwJ681KaQN/
sRSCs+hNIrVmXjHKNGPJnTh6uvm5LGi/lk2zVkRa0A0XrWCfSkkYAyUaqminYQXLVmQXLG3jhhTL
E4sMs2fS6g22Vq7fnG+r/B0M2CMMkH7pbsBrpt/AvbX6Ep5MubJlpzpdIJarWANjWfMeQ54l+bLp
06i1dkaxOW5gK3BGiwvq7lHr1Lhz67Zs+G7p3Rg/D4BEW6jsb7dPG1jOvLlzIsD/Op/eHPrg3mV/
R98qdduj4sfBJTdNvbz17W3LUz+fmKaYr5y7rwtPf9D4y1p03w+ehTD2L//wuSYfP/XRt19l+eV2
oEUJ/vUfVA689kNsBRoC3jsLClDAhhx26OGHIG6I3oiVhWjiiRw+CCEDEvpAYYWEXDhOhijWCCJO
7MUXhH82uWTjjym6t6IKwslBHG0wSkIjkD/iqJ1qRF3X40hM/qjikPeQEcuRViXJ5UBP4tfimHC1
dxCWNhQZC2hewhMmb2TG2YSDmaGZHRZr5qnnnnyqkSGJgFK2mp1QaNHnoYgmesmUgTZ62qCEssZo
W3JWaumlmGaqaZ2RTvGnk5uGKuqopJZ6RKd8fHoUpxC8ydSZneYoIKwRqPoSpKimY6lvleZaQaYj
XOorNsLeKeewEwArQrH/yDbr7LPQRivttNRWa+212Gar7bbcduvtt+CGK+645JZr7rnopqvuuuy2
6+678Ma7k6n01msvPvLCe+++/PYrZL7faqnowAQXbPDBaVwJcLMCI+zwwxBHrMq/Cw+p5pptZiyT
wbhWDMbFW2os8jcHd+yxpwPmMfIiMp4iWpvV0HryJsKx087KOP+jFBAzu1IzLS3nLHQ4O//QczYp
pzT0ygExEnQpRfuAEL5HL/Dz0iI3zTKSXbVhck12VW110mdhfZyrRsU0TtQ9XGCrS1+De7XZkKE9
GVezsL3EbnEHTHZedA/SpZtR8j1EUl5tYbjM7M5t9uAYFu6oRvZOzh3j/2NR/YLjgaNl9+L0Wk5R
3x+8PRLpD3AezsuBfy7668Ch3oHpbrG62N+NhXZz5xFJDvvvjcrOyaRs3fsi71tDzgjtwDcPvMLD
yxqWvccjn8jTijDv/PaWQ0+k67ipfj3X1mdPPHnqPQf+5Omr7/usPP+xPmriI4J9+YRoD2WZ4Z+P
Gf9mit97FKejIVQPf8vQX1YalBpVMbCARksVAeEnhAMW6H7SWFKVUMQ97m2wRt6DiqUsWB8MJtB/
FfkgBzvoPBWeKITYAtlUvkM+rGnQhR8C1Y6ktEM6Se8iOAwRDK8lQ+84woQju2EQOaRDIPCohwF8
H0aW+KEhWquIlkDgKP/mR0F/vcaHVgwXFgOhRYBIkX5eHBMYKbYuQ0nsjXBkgwJZODnhpcuNccxj
HudIx+DZDl18HF0aB0nIQtpLbGP74QL/2AAuziWMC3Hk/7wXSMphDpGvUGRTOiZJ/rARS5pc5CVZ
hMJNMhKTjdyVseJ0RUwFS5Wo/BUsNzdLaSkrBMyKpS53ycte+vKXwAymMIdJzGIa85jITKYyl8nM
ZjrzmdCMpjSnSc1q6tKQ2Mxmr6xpJ21685tv4WY3wUnOcjZBnGgypzrXeUp0hqFheoynPOcZCEi6
kwXwpKc+98nPhH3ynjXIZz8HSlA92hOgnsEdn8qIv5K1E6GtUuieGFr/PoeOEqLJkqieKGo9ix4U
omNUGUd559F/YjSTBgzZ42poCtYlKWYfRWhIlbZS5ZXCpTCCqUlPGlGBCoJuSKQETiuk0yDwNAMz
LVtNhzoKptanqAI8qix9qlSXsXSkHNXbDqTqNonarBtYDWsAtKoDroItpTOcRVDFStLEXdSsiUSr
Ea1qU7YWQmtUIWsOphY2RNbPrlfZwhGvSgq9AoCvV0DlXwFLOC0Mtq6jMCxiraBYrzJWJXgdH2RF
IdkSkMWvlr0scs44PbyJo7NT7eTlYrqtxYr2FKplUBE44tYhVrIwOzWXa19bitiuVgi09dpDD3Bb
O2ZrtyLbrFpIaxrT/5rCsLdtolH1FVqgOnW5UFTQ4bomxwn27626re5SoRHY7H43CIgT7gPRCN5y
IVdjys0gc8UEXO6uIUDnzW1ANacZ8fIWGL69W+VeZ1xJ9ZWWVAXcf5UR4CeGjsDD5VViVxkEEi7Y
FA3m4YNFV+ATfBbBcp0PPK6bswz38cQwifCySvkqFaO0gj7RHVh5Z2IU2xi30z0Mi9Pm4lr5d3W7
61yNb3ziDnc1lDxu71lhnNZ3kBhnQyYyHY28ZPOWll4Wxt9a7bNjKXu5gfpFapelS6osl2/LvUPy
l9dM36iuWM1kHpWZrYfm6LL5zqvK8SvhnGcsx3ikdR6zdNrHHD6/jv/Qhc4waz1gaO0mOHePfTLy
7OwSRC+n0ZaztAEwDbcwezjKxfuxZiVNY0H7RdOcdhSqFe3pyf4Awo9mk0AIq0VKj2TV820erq0c
aj1T2AewDjGBIh1kjtq6dgBkr93WS6lWSzjZV2byXC9sG1NDUDCOXnZ/NBzBATJblMJuCLUjcexH
Qru5gv52tKUmQXW3ONbDGTckyj1JK+gn3dteI7tRNcI/JwnNpNkxFT2E5xENvEOLRpqc5jwagMNW
4AcXUcG3E/ENJZwV/W4yjByOYYhHfOIUr/jFh5VUBc86vlnz+MHjzAMHv5rbvLZIxQswcl+VHNIn
J3WblEhFlu/A5cD/hrkTfSRyZ1Pr5rJ2Gq2hrPKB+1wHQO9B1F9OpaL7Wm4ajcOXir00ni/x6TmY
+s+FTnWRzLzmuUL6HOQNamSzMwn6dnPjso6xcbfd3G9HQty77S61G8nuuUZQ3uEexau3MQsFTfya
6A3yLsq9Z3hUvOTxwPjG9xrt44r85Dc/h8pbfn+YF5fmOU/62gb+87HrsbeKO/jWu56QlU11vV9P
+9qXKvYBtr3udx8q3J++z7wP/r7gCgLWK5m4vwf7YfnNavAaP/TUlb0nbZt84POdUNKf/eNTmX3Z
Ht/3MTfl8ZtveFCSP7fPN7rYctnfbR7dlW8+ljHZ3wL6Q+uWxa+l/zDtvwL+Owv/paN/weR/KUCA
yAKAjCaAxLeADNiADviAEBiBEjiBFFiBFniBGJiBGriBHNiBHviBIBiCIjiCJFiCJniCKJiCKriC
LNiCLviCMBiDMjiDNNhMwneDw1eDCYiDPHh7Ojg7PRiEovKDQCiERgh/RJhQR7iE8peEYsaEUNgi
TqiEUViFXTCFpCRtpbeFXJgH0DeB8NaFYjiGZ/CFEhiGZJiGXGiGEYiGaviGm8eGEOiGcFiHBSWH
D0iHdriH+4SHDqiHfBiI8eSH9+R3WidvL8UxqgdSdKdSiFggJVV+DWiIDPeIaBGJ2zeJjZgMlgiJ
ivh9R0WJ/taJx/+Bidc3h5voCaT4VJ9IiMskihq3ipBhivt2hqkoYljDcTnnDBkDVaeYh7c4bLm4
dKGgc3vhi7XYhsEobuM1Y2rFdTmVClRWTWoXOLqodM7oJcjYNhRYjdaFcpNgjHqxjTxQgd7YjOQF
NNBIVNK4iDK1jDQ1jOAoCeKYFuS4Vd0Ij1W1NNeYPOkIM+0Iijz1XluXjbIoMqhli/D2Vf94kAhp
epLIgARJQ/PokKUIkZkokaImCv1okXmFkb/4hxtZjMTokfSRkMq4kOpYkSZ5jCCZjKioks/IkhSV
WSuBkrjEX0czkS3ZCDZ5CB2ZCDiZfwdWNTzZk4vwkxZSkpQwlAH/WJQ7OZJIqSSCtYsGCTUvyY1Z
MmGgJZNTuUVViY31KAxZWY6epZOQJ5VfWW2OZZUN+VxliY9bSVmYdJRraQhKWQhBiQhOuYNcuX5q
uZbnh14bo17U132/FZHnYpcWOZhAEFzdNX7Wdjru2FqB+ZWO+QOQeV/DlX6KGV5eeZeIkJk+sJl+
0pmT6Xbqt3qXOZWk2QOm6U+SiZiCJJB+E5qiiZfVdyuzVZiReZi5V5nawpgO+Zo8EJtowEmpiXer
2S3EeZB3p32waV+n6W6CZ5usiZv8OJYdF35t9pjUKZv5ll+f6V6tmTE0GXDeCSf1lTdxSXjZ1pzc
8pz0kZ4Pt57X/0mY7mmY54Zu2Omc59km9tmdQwc64Lmfv9mf31me5EKf4TGgvbWbhVea4Zmc3gVm
/zmfAeolEEoK0Tl9B3oK0LWclyeflqmdQ9OhYImf0XEvsDONe/aX7Rdu8ZibusmiqVcvLyqcBYiW
/behJvmh4rdh3cOjBiaj9QekHimk70akdWSknwaV+KSkjSmhGOqkjgKj8UeXOoaiNsqkSYalfpSh
PyqlZUqj+2ij+WOlymYqO0qm32OmcYqmJqema4qjBloqb+qKT4ikU+ql08CdMAKmqJejfIp8tGlJ
I+egzLCOXcemhco9WvpieNppcBpXWiiM4yCoFUKokRqfDDqXlf9KmZeqAIwqVI46NJ76qVdqoreT
qDiWkX2aqczoZKkqNKvKqo8CpUcWnKWKqHSKc5t6qyUGqbr6O5PqY7DaYacajsTKdKN6rM6TrD3l
q34Wi2UUaLAqrWyGeZ5ZZqPIUNqaq9yKrK4aISQKoqVSiZ0zrsZargXnremaYvXCrtbIlHdKrvDK
YefKfdZqKvb6jTrnefsqqf2ahf+6ruGarfjKZdtasDcmr8tKPQurRe4arWyxawV6YhpbdtemlTH6
rokJrthqsQ2bZmDasUGHYiordWQnl8/msWMarEnnj886afPaFC3bcja2s2O3dzD7ay5bpIC6lCoK
rSlraQ+rGz7/C3UvW1byI7KC0pp7yTQ5yxRNG3Y9q7TGuXwzsLQfOyEVaz8n2zpXaxRZCwA3lrZi
B7Vfq6/MWWFjC5RlSzcESxFsu7WI9rCHiqkyGyjvVbVJdLZFkbcsy7VSO7Jm+baJ27Y8wK6Cm3Jg
q5rYRp4bS6pL8bR71W7jWaJyW7KyeLeKam+germUOyea67XvdKHrBgQBS22iG6tMcG98Zp1DGpJ1
0bmtK7agu4qxa7uO+7OWmrlA67arC7zW57pzS4q/q7v+qW0KCnq4WyisK72fO21VOrlxi7qW+7fS
uTepqyvIy5tF24nNG71TW7vO67kg+zHVC260WqMVErnk1nQ9/wexfnF2Bxul63u714uLG1e3BJpr
M4e/+Wt1sgohGYe9FyTAEYpkBWzAcaG/oaoQCwzA8+vAHmq/XyfBbUHBCSxClfK6VEG/88bBQeTB
H4zA07siF6ypGXy0lOB1KazCYAHCLazAI7y8smHCbPkmEWzDWYHDMDlOC8fDDafBKwrEFad8wRu8
Tlt1Ede3+XCObsmLZkPDOOTEqQvFWivFB0fF+qCPdWqzV4mrKLzFyTu0E8qzxevGZsfCRZydNLt2
FMmp4aHFLsTFb6y2qRvFcTzF+5t2ZCysZvyWqprGe7zGcBy2wuvIbBwSRNy+WAeIGKwIPuw5iqxC
fNzGj+x43v8rc3JMybdZx39HbGdcrBDcxIz8yc2Gsacbyik0yosreoVcs5isxPVJG6tqhH1svO0C
iwz8Wr0shL+8ue8izJdMzI0bpjx4zKobzLdsx7DbzEXhy55MyuqizDDMW8UchNAcL9xcqwv2zT0Y
ztFnynnCdtasfLWHzsk8zad8Yeb8zNlcy9KMJ4I4ebGLv9QKoPq8z4rXzxD7zxoa0AJ9h4QrxNub
wwAzegnNTwRdsAZ9olcQ0Yk30fta0cOJeBhNUBoNrxx9XB790f0U0uU60oQ8sVbY0kEIfqa7uy49
07YH07LcpDSd0zVdlwut0z69012ZsD891Otk0ysLykSd1N//ZNSRzL5KrdQh+K1PndRR3dOoCcsN
jc9GjNXfq80I27gqDZpC7dCmas2/mgl8e0pSPce/tNZeja5cra5s7cJdC8ypY9VnnS9urdXKGtNO
zdfmZ9ZqjddibMssDZw37b9zrcOC7XyEPcjystdBS6lN/coVPMaNTUmPfdlBzUpCKyGtZIAYINq7
RNpn6X7TgoBF2ITEZNqiOiahrYCvytrD5NohSyaxjdpPSdv7J9s9qtvRotrRw9sD6NtH6tnvZ9sZ
BdzFzdxnitypjYRESdxYWN3Wfd3Ynd3avd3c3d3e/d3gHd7iPd7kXd7mfd7ond7qvd7s3d7u/d7w
Hd/yPd/0Il3f9n3f+J3f+r3f/N3f/v3fAB7gAj7gBF7gBn7gCM4BCQAAIfkECQcABwAsAAAAAPQB
XgFAA/94utz+MMpJq7046827/2AojmRpnmiqrmzrvnAsz3Rt33iu73zv/8CgcEgsGo9IHGDJbDqf
0Kh0Sq1ar9isdsvteq/JsJhmHZjP6LR6zW673/C4fE6v2+/4vLw67vs/ZXqCg4SFhoeIiW58f41/
WIpwAZOUlZaXmJmam5ydnp+goaKjn5GLYI6pPJCmbaSvsLGys7S1lq1sWKq7OayFtsDBAQTExcbH
wsfKBMLNnYa6vNJkV4fO16/L2snaxdjfldCo0+Qvvm7c3cbg7O3u75tw0eX0K+euwerI8PzAX1H9
QMkbF+IflHpD7rEJyLBhLINPPOlbF2ygFRIQnSAUonD/jcOPIANUEUCypMmTKFOStMLQIqMKVlTK
nFly3kZz1SSF3NlvJM2fKFkGdEnlQkygSFcSvNmioxqe3yYyC+gzqdWZWIQRhXi1q0qbTFk4TQMV
m1SGVb2qFZC14puMTdbKBRtWxVg0ZfOCSyu3r0y4gJf4Hfx3ad0Ud8/oXeyML+HHgQE/nlzT8OET
ic0wnMhYmGPKoEOLHv2T7mUNcON0hieVGCgspGPLnk1b7cvTEFLrXN2u9VRPsGsLH0589m0fkZNT
Uf3JN293wYUbmE69OvXoxQlb3z4dO+HjPZSLh8JcYuvn7bzP5r5dfXa57K277wsehOT3NDMPQM+f
0nzS//cJ919QcIFWHyAF4odVTm/019+AogVYG4QmSfhdUSVQGNp4UZTnICXO8UNhASSWaOKJKKZI
ooIsjqbiizCWeKAHGhrI4RMefjjMeSJe8VOMQKbY4pCUBWmkjBjOcOMXFOg3Bygh6viajzQdeSRQ
NVr1GX1bpmSlkTPiBhODuJAlJVpUFLfkmlxRZpqYIjiJyJloTqEmm3h2YeNFcGKmW5mABipoGlkS
aehgb/bpAmCDNupoK4UeKqltlim6KJX55anpppx26umnT1iaEKYLgmrqqaimquoUonJEql+JOtDl
WnxudNRksSoQaaa1toqcpjJs2mqnIwjr6w/GwpCsov/ExgnssdBGK+201FZr7bXYZqvtttx26+23
4IYr7rjklmvuueimq+667Lbr7rvwxrvBqvTWa+8/8n5777789ntQvtAG8ujABBdssB1hAlyOwAc3
7PDDAyesMBJyWkPnxfA4muvESpA5CMadRGkLZxeL0yvHvzL8McibiFwLyXSaLDHKNeiXDsws59zQ
VlLQDITN+fCo89D98ByFz8h6vBDROkdm3llunXIygoBxDDTTLDsdstC2GP2vs1VPfDXWvM1KKRUt
vVXpA7dCtra+SntE9nNmeyVU0WpPHUHbF+od7thzQ91TmsS1BYzXXtzpN7iAz71Y3fjZO2lli8Ot
8tL/jncG+XuST87W29427g7Ojm/u+enZbcyt6L3pk7l/hKMuu6GqH/snOq8/Tfomu87uO+ozH3Y7
Prlz4jLvr/6u/PKU9xyEv1Pk2DLXxWfSe1/xVXe9odlflzysSRab4KSsY3J89ZZsT+v4E37/Fft9
O48R/LTH/RT60LkPIP2yZWkhouEDm+lwlSfpOeh8e9FfSb4UJOb5joFACh4G1Lc+PBmwPwj8xogg
CCMHzo6DMJKgCe7VJPvhAUrUY9oGQSik0ijwbFLYk52QwkIViTBD9irh5fSAQsGRbYU1NBGWXmi3
2LnNiFUK4oluaKmKGQJ/sxjgEaE3HhkGcFxO/AUU/x+CRNpQ8UZWZNW5GAWxMj6Mgh6sX+XapaEv
uvGNcDQV0ibQxjja8Y54VM4cJYDGCoGOAVIc4hUXFkgXrvEAfWzeIPeoKyJq6Y8LKCSvxGgrSZZK
YolUyiF9tqxL5WlYnBLfJxnJtmcpy5TMCqUA2UTKVrrylbCMpSxnScta2vKWuMylLnfJy1768pfA
DKYwh0nMYhrzmMhMpjKXycxm2ieP0Iwmk5zpB2la85qQpObzsMnNbjZBm33wpji5Cc6kLceM6Eyn
OvXAxHI+85zrjKc850moRboTMVWgpz73mc523lNWJmzFFqGosWxqM4uEGCj+CrpJcCJ0ZQotHkP9
qf/MhwoiotWbqD3/aVG5BS6Fs9jdh2S20Xt29H4f9eHLXFeyQtTuoAG1Q+YyCAuROoiklPwnQHeY
h5mCVBY27Q9O5afTBpSvpj/FqEIRp5Gi7jSfuxlZUpW6RaZ+06lGjalmgqZSqkbUqkzAalZ5aiav
5kVrxpvqK8C6hPmFTWFHNWtD0Dq9rs6CrQBwK1ysptX9yLUsdNUETdeaN4qW8q0Ai+tfm2HJwqBt
KIUtKSAb+76Gakuxiw0GZVNyN37g1SibJZBls4XZzNoitCfpbMYim1M6otaPo8VWaU1Li9cqEiCQ
lZoE+QbA2F5rtheza3q6aJwraIW1BlGcYaUFXDr/CZcdtk2K4bqG3H8oV7Kr62vunpvAGQ5nurXA
ayYf6VtrNfdM3NUgcYnUucm9lLTapS101zuk9pLPoPAlK17k290Yes6+knqvbOPLX2xE1031Op2A
f0tgfgSVaAdOo4Rhu9xonVeq6ihehCc84QWbt8GsYenrNszhNHq4WhdeaYZzR+ISO/DEcBoe5jA6
2EqM18U45lJrm5iRC0KxxrDjbY6H/F3sCq/HUY0okCdxYyI7GSkVnoaMPUpjtdrYkU/OcvyOtk07
+vgSSx4xlifTve6M2XNlNkCTo4xPLye5rg9GX5N/kuY5D6fOZ4aykanh5gY1x8oazvNg8Czk5RH6
/8BsnmxG/gviAk+p0PtbdJEhLdo2EXDH77R0gBvtaOAIGnySFtCY/wdqoq7Suu7ldKc5YedLJnfS
piO1jk1dkE+Xek1ffk6Y/UFEJZ5Iy/jxtYkSHUlbzxrXbz4goD3Ta2GvCNjZcTaJiN1ISo9GU7nm
za5P22xnQzva0qY2Io09lwImG4PL1my3hf3t4ki7AOIGbb10CFUt6k7EP1y3rwWJaPpW0Ls/Cvee
R5hD1+pXpn9OL8iAuG9D9hvgW45CUt4dbwuQ0OD1TmjC47xwfSuR3/6GocQRHHKTUHzgOJw3xuGp
8XuvOKUTabFjx2ntIpYXxX1NxKqZXPIN0Zzclf+teIxzPqedyzzoP4d4b4UuppOe0Og9D2PSoSB1
LpuLjPzM+iBa3e5bYxpeWNe62O/A9a6X++ZYnPLY115dqpt902gXV9jZTnfdRv3too773xw59b77
HVSzrOPfB0/4JQWe74VPvOIDc/iaf27xkI88Fxpv25c+/OtSvrxkxwvjeHHeoJq3OkIcf5WNfV7v
7zr9bu8uXaaHJ/S0firrHY5yuKLSk3gCZSdpdHtX7l4svW+6Kms9ylj+3h7Bx02ziJ97sTr/+dCP
vvSnT/3qW//62M++9rfP/e57//vgD7/4x0/+8pv//OhPv/rXz/72u//98I+//OdP//rb//74z7//
/vfP//77v5aSF4DD93+yJ4AGyCEEmBsHuIAImIAFyIAQmBEO+IARWIFeMIFjZYEaeIEYWGwb+IFb
0IEeCIIkiHrjd3B1l4IqaFXvh4Ir+IIwaAauh1UuGIM2WHcz6FQ1eIM8KHY5WFQ72INCqE8/qFNB
OIRIuE5FaExOdwc7FzON0nnI1IQI94Q6olGYx1FEZzFWOFJRiF/ORIV10IVX+IUmWExiSAdk6IWD
IoXHlIZPsoZCZYZLaEtwKAdyOIdtCIbNdIfZloc8gYWxB4RbeG46s20upwwYM1SiJ1Zp6FMKNwoc
xxuM+DXO94iOg4hb83JnUomhAn2YCHP4pmLd/7CILsWHzBSKZKOJacWJUuKJTfV8qog1rAhn29BS
hOCGTFiIfiaKrkgLk7gasHhVsqhqsVCLgLgYnwWKxohUkZiM/LGMxeiCNzOK0AiFdpeFJtWM2ZBu
1yiMbfeJ05hxxINhwfiN4JiNg2iE3EgKyIiOOyGNl9iOkuiNERVYUCGPmSaBYkOPovCO1YOPgRiO
sch8/Gh71AiPvAYYiXiLh0OQxGiQEMFXCamQtSCQYGaPnqCPVLNX/ViRFhlFgdGQy3Bc6tiIHXmQ
ieWPIdkJGGkJAHkJHMl7iJUvKdaS6TOSm3iOAgGRYSVKHomQ5DhjOPkKL1kJMXkLPtlWQKmSNv/J
khYJexGRW21geVJpieRyk9B4lU2QNieJlXzElQWZlVCpkGK5BF5ZlaB3lj95dWUJj2yZlrmwlrMH
ctrYLVqZjHFJlXNpWaRHXnV4E3kJiHuJN18pjvTmX5cWmJUEkkWJCYXpWUuZVxbHlkzplo65ijyp
bkpXXKpFC+IFdCJ3l9mVmbS4mQupmHlXBSaplrIWG7poO28pJc/IWHW5dI/1kIepJ4WDijhnmkxT
m8zWmf1jXFHjmvxTnGfoK4OJHsLJmarZPp95V5NZdkhHmtvSnLqGmtxGnLBpnLqJnKG2mrX3YcBJ
NM+ZmiMHa7lJXbvJBdeFnZc1mzqSnt0ZnYf/AmD56ZvUop1reHRntyoKxp/T4p9kCKD/pioDupy6
N5RU9piXgKCjiSoLypijR5/XKKE2J6CMxqCiYqBdqKFdoZ9qZKH1AKJWKKKll2AdaqL0gKI9VIos
dpt4pzyxyZwYGlLWmG/eWaMldqMNynLlGGK/yKP46aM4BqQfmqNAtaNYo6JI6kUEylxMeoxOqkI0
GqXAM6UWVqXOKKNi1qNa6kFKymPnCQ7cKSVQOqaR5qKEdKZRcaUQlqVsel8eKphI1otVVpvWWafM
46Y/k6e4o1Rh1qd+aqPleaEQ8YfblW6Geqi+A6jmZBCM+jqFKpqQmqSJeqKCOqQKdal/manA/yap
KUOphog/oLqmorqf8hkWaldWe3qOj7qqLYqSq3BHlZqUHReq2lFmsyoah0an14mYpQpHuaqRRiqs
XhGsYjo7zHqkxwaWt9png7qTcipnmHoVz7qeabStbreYtroDeHSs9vmk2WoV3voEHJauTlB1EVms
b0SuaYqlvDpovnqu78GucUFy69gLuHqqMIms5lqvfqGvTLCu90qwkxSuNKlprOqgKGWtRbpFv/p4
DuuZsZacAcqwHfCaLDJbuooxFeuxETJqGpug0pqSqGanEAur3ziyJ1uyf0myG8qxHECznOOlmQWz
4ymlMxuzNZuyDftqLCukRJmh+KpnPYuxd/+HsyvaqpW5tOyls4vFsxernBkrtdFKrBL5BbUaPQD7
n0lrl17Lns16W2ULrkLbsUCbs3Aqtgq7sSsrnVl7tVs7ll3LrXSLbHpanwIrCwz3cbQqFycHtWMS
t3J7I5W6GCH7aHXzboNLuAJnuIl5tu4qHourF437CYEbRJG7FoXbrzc7tmTLIZmbF5vraTUHuZ/r
FaFrs/NCurS3JKdbFqnrkh7nua3bFa+7tqOLuCiruGG7GrfLarlbQ7vLu5MrurELvBNqusPbGcWL
PKsrbcl7Fb3LtTihcmF5hH1rixPbNMfLQqX7rVOkt7iJvjKRvXi7vfRSuVLAhRILpsnarKz/u7CW
C5jqe7ftSkPLC7tt9r4rB7b2Nr8OWb/QanLWO7sJHLz9q7bmm0TOZqIX170t64Qbd627+rgLjL8N
/Lz7CsHq6r8TvKn7qCrw2yFPlMHhmzOdi7wMvL+J+8DnK8Mowb7vekrcuze8WK2tOK/88cLkG8MR
nL4jLMI0LMHCRsEFZ8FGy0MsTL8Dy8HeRsRHXMNXHHFFvL7/67t20cQ87L0+DL5SHJytAaWSd7nt
u3diDFGdhsaRp8Y5LHc97MaOBseQJ8dtWS5+GL1mhceLp8eXycd1fFFQl79I/HeCTJmYecGm8ISA
rHiLjC59/L38FcmJN8lj9KpJOE8VC6ll/5pfi9rJPii7yRvKA9appEyEpry7qMxgqrzKntzKrfvK
5jnKsrxPn3yotvybuJzL9LTLftrL/cnJwGxGwlynxFygxnzMEJPMbLrMVOp4JVjN3kR5PWfN2nxN
2Hy22/zN0NTNH6xJ4FzObyTONoy25rzO+4LOW1xp7BzPFQxLgifP9izAxoevVonISku5Y+C88HyX
qmfCYKfPdDnOI0qqTWGZjBzGlcel7jLQ8smWTAHQqfVHEs28nGTQfqmsAe3FqmDRFLZ5HK3QzCzS
FrtnFI2nHn3Rh5TRAEwzMA3S1YbQT+vPYoDS5EzSKC3NjJN8X1x8qXR8zdt8+SzUuMdKS4hK1KgB
1HvE1CgA1XWxfCdseLIk1QSH1EOH1VFr1PTs1FEN1lM9gCpr1Uft1Um9JkGK1kOr1lct1lnN1sLH
1Ycr16RE103p1mYK112t1CL414Ad2II92IRd2IZ92Iid2Iq92Izd2I792JAd2ZI92ZRd2ZZ92Zid
2Zq92Zzd2Z792aAd2qJNAgkAACH5BAkHAAcALAAAAAD0AV4BQAP/eLrc/jDKSau9OOvNu/9gKI5k
aZ5oqq5s675wLM90bd94ru987//AoHBILBqPSBxgyWw6n9CodEqtWq/YrHbL7XqvybCYZh2Yz+i0
es1uu9/wuHxOr9vv+Ly8Ou77P2V6goOEhYaHiIlufH+Nf1iKcAGTlJWWl5iZmpucnZ6foKGio5+R
i2COqTyQpm2kr7CxsrO0tZatbFiquzmshbbAwQEExMXGx8LHygTCzZ2GurzSZFeHztevy9rJ2sXY
35XQqNPkL75u3N3G4Ozt7u+bcNHl9CvnrsHqyPD8wF9R/UDJGzfin5N6Q+6xCciwYSyDTzzpWxds
oBUTEJkgFKJw/43DjyADVBFAsqTJkyhTksTSzyKjClhUypwpYN5Gc9UkhdzZbyTNnyhZ8nNJ5UJM
oEht3mzRUQ3PbxOZBfSJtGrSKsKIQrTKdabSpfZyvnmKLSpDql3TnrSS9U3GJmrjriQINmygsWTz
fkMrt6/Mt4CX+B38l27dFE3T6F3sjC/hx4EBP55c8uvhE4nRMJzIWJhjyqBDix790/JlBZGrxOkM
LyoxUEdJy55Nu3bXlxtTU1nN2p1rqZ5i2x5OvLhs3D4iQxaL7tPv3u6E1zZAvbr161aMU77O3Xp2
ysh7KCec+Qyo59DZSafdvf137YTbd3+/fEoN+vBVljeTvj+l9f+zSUYcgPq9BVp4OFGRH037DeCf
fwSSJuBwEQZlIHhFlVDhbboZxNuDlaAHz4YCFGDiiSimqOKKJi7o4mgsxijjiQgCcsVkHXqoE4iU
iPgOiTMGuWJV+A32WV8klijkkibW6EGSRBrWQYNznOcaj6IAyeSSUSqI4RQH3vjTlks6yQJgMlCJ
CJYNHUlbjnCmhqOUOqAZg5rWsDmVl8bF6eeF5NF5WkFv4WLooYi6JeaLjBZn2qB3FpropJQaCmWj
mAZ6EaQoXArUn6CGKuqopJZqJ6f3LWqkqay26uqrUtRnJqpIeFqaoA7Y6hWuqui666Yw8SmrFLQS
QWqqoRaL2qj/hIqqLEfMUpOssseKUO2z2Gar7bbcduvtt+CGK+645JZr7rnopqvuuuy26+678MYr
77z01mvvvfjmqy2s/PbrL6j6rvvvwAQX/E/A3d5V6cIMN+ywHrMiTI/CD1ds8cUPRyxxEngaoufH
Q1H66MarMDcIyJ34WAtnH4sDLMlBdIwXypqoTAvLerqsMcw2NJgOzjQH3ZBWxPIcs8kL5XOl0EwH
RHQURh9NcdJNBx2ZREsD8zQUGL3Fs89VWx0Y1mZVpOjL1noNM9hhQ+dmXELBs/UTRqnq18jost02
a28fh5XZp0xIIa/n6r132T0JO+DfWp8N6OBot2v43ov1vWDB/5muRbi5k1Oel+X5YZ55ZZuX23k7
QFMO+uisv4h34Uh7FFDqe6/e+u3avQ7WqB96nnLWnfiK+/DE15ThTbzv6DsnNmsivFzycVdk8SZF
j53tXRYNxHirTi072bQvj8nzcVnvHfasm1/d9Egenyb6kKumPPPAi58J+WoJXtul+nev/fuKw9Tp
7HcN/KWlfwGy26+28qX/pY19+SNY73jUPHBoiUwzop4GlYRBGe0MAwY0ScEmCKIK7kWBKOlgBjdY
PBV60H0PhN8CYfik2OXBSohr2wVdqKLsxaqBUQgTBE3CQxZ9sG5D5FDkpmRDPOAwfEzbYRFP5MMg
AhEKQgTdFP9VdEQNqQ0GMiMEAWkhQ00ZDFRzWmKdvpgg+VFqjLMoo//O6Kc0djFcgMGYHvc4gBCy
UIClI5cf50LHQhrykIjUyNpQCLdEOvKRkHTV1xgZQTU+YJC6a8QgjWfJXAXwbnfc2CYzeQBMBtIP
owykHAtDw6hdMlozgCWtrgUCWroSArYEo7OoJUsb7fKWwAymMIdJzGIa85jITKYyl8nMZjrzmdCM
pjSnSc1qWvOa2MymNrfJzW5685vg1GYkx0lOOIWzV+VMpzohcs5UrPOd8NRCOx0Rz3raE2rzhNZu
+MjPfvoTYq3MZxul8M+CGvSgawilQGNIUIQ69KH8VOhCIxD/xpPBEY6VIqVAKyqIi2JUZKfMJ0f1
4NExZjSk8xzpDUtqv5N2cqILUKliKGdCWEDRPzoLKEwZIFPN0LR+N9MHyHJqn51KoKfm+WkOVybU
lhVCoyJt4h08V9NsNDVnT0VpOwdoU6Cy9KsBmNtBjEpRqSYVGFUFq0kdp1OyctWqS1UrS8XaBLKW
1XtOkaterva7uM6CrookwalEaVb+6PVzY+vrTWEB2CV0LSOTxOtMD/sUvtLPr7JoLAAey85FStan
lP3RJ9vHlpAF7oOpfCnsPnvW0LZjlQUqrdzYWtRgJZErUMVjYR3k2uiMVi5xe4dmkQhbzamWc7vt
rW/BdEUp/7SEtg48KiUPqNVvvTVomFXPbxNIhbacNiN9qq63rkuz7Fpwu37rLuDawL34tTVvyfWd
eU/I3PCqt3HfZeDijmu6+FJ1sfT9IetEN2DxJsy/yg2wFQs8sNvlFlzkTXAc0Xs5gjnYwNyKsIQf
QuHQWbh1D7YugltzVc8V948ovgp/BTnidwBYaCdOsYxZKdEDs9aw/Xix2Oo74x4Pq7bv0vDPtrG8
GPv4yCEmR/Jm9tW0/me6R47yfqM7MVGR0KNOnsQmpcxl6gIZIUtuDlizLBIod/nMoqlxGMKMjzF7
9X5mBo36qHPb4c3ZAHXG7Xtv0F7SulHMii0xS7cMlDvn+f92hjYy6b4snsD8uKFMvqyOixzn7cz5
0K1LdIf1zOiSIbCSf25zoNUhV0L/RNM8RjGqBfzoscZS0c2NwpU3HJxK29EgjrL1p4G7Z8TA+tZT
mDWtOWFqBj1uf7o+9hy5Jq1UA/LGvB12KIo9w3/k+tC7bmSnfanoEc6vhG9WsLNTuEUUodlF5UaR
ml+J6VsNTNjpIbM/zJxuKp47P/VuUq8zkNp/wRs68raFFLd4b3zne92ebLex3/3tBwW8FgOfYsHh
k+8CILwB/d63bUPdUeeEe8eWq3gVsRhruJT8CUCp+MV5autPYZjl0K6Sx+eLsogXceQoP7lgdG5y
mqhc48T/3bSKV45UHI+6Gyamd75x7oQsLrjVJPf5wYFugYxvewNFj/bRiXw41/ya6fcct7ZXzlmD
AJDjh5K2loXu9LArvNpUXiNkIxXzNan960N3+9tpTHVdzt3vaDeU2sss9jfp3epxV1ceIcp4QlB7
4mbse5Al1fjK4+HxkAfliuW1eMt7ng6YzzyvNx+vzn/+9NB9uuifTXZe7j22h4+97P8V2W7P/va4
b1Xt2e7u3Pv+93HafeG9DPzi+wvqdLMm4vG5cd7DnflVfn1KSPlr0pNs+cxu/vA53frttbz3Gq9+
9zn//YWjtvx8v/ousJ987ecc2Nk/Zi4H6qdnzZ8D9xdm//7P9MtZ9rKG/WdM+2cXAON60xICA2hX
CriADNiADviAEBiBEjiBFFiBFniBGJiBGriBHNiBHviBIBiCIjiCJFiCJniCKJiCKriCLNiCLviC
MBiDMjiDNFiDNniDOJiDOriDkmN8PsgvPEgBPziEkhSE0kWESPh/RghzSdiE9beEuOSEUmhOUMhu
U3iFgVGFVoiFXGh2WohxXRiGB6ODdYd6ZniGuSB5HFiGaNiGaDh+EMiGbjiHnweHDyiHdJiHjGeH
DoiHeviHB8WHDeiHgFiI/SSI4ZR1Mjd4POJSiBhNiigHjIgljqiGibhbiTCJjQhS1rdVmGh3mohT
nPiI0P8Uif8WinlRiepnVKbYcKjIGKqYeG71iXnyitARi9A3iLToMbZ4i6NoieCUdf9Fc6MwaZ1B
VLLIirsYaVXzcFtHEVjleC/XTcKoVMb4RFzHJsiYiwxYjV1HjKJwjYuxjfGni4T4PW3jjJKWjZSY
VZ14Tt6Yjh8HV0jnVNL4jpd4jnn1jeIIPuy4ifdIis8kZDPXj714jKlXjt3YYrOgjgepF8MVhwwp
Cw75kGQRkXc4kbFQkRbJExjZhxrZVeDYkQiZX8m4UwTpj6RGktHIXtPITSn5jMowbJaVignZfs3S
WdcXkvQoaMpVkxd5k66Wk164k9DGkvMGGCpZj/jlkvj/eFc6SVhHiZQQl1jruJJNmYZPGYV/J5WB
N1lUKQtAmQkciQkfyVBjaJRfCVphyWFKKZP7kJUJ9ZJMWJReuU/M2JajMJaYUJaXcJYIyEYSE5N6
WQl8eQl+eQtCWVde1JWDyZN6KX6yJVyLGVhCiH7pd5LINZWFCWfbR3z3RZkmyY1Q6XzPp5DsQpgP
KZmMI5pOeX7SZyFbuS2qeZCsGZruAJil+ZlWkWQ2tpat1ZmWcJtT8FyjiZpcaXuzuS+Q2ZbE6Vym
9Zrhh5mwB4wZ1pxhiXcuh5u0oFmhN310aX/YCTIj6RmmGXkAsV5aqWyGt5zZUpv+UZ7BoJ3gl55y
qQZ9/4Zs7okt8Nkf8pmUrDZl0HmfaZCf7SmQuzOeH/OfAneeywYF3iWd+uVeq7hawGl0/MiU/ECf
5jegtuCd1Dl6CLoU/RlvBlmVvJlmrVkLIBqboGadtKmgesKgKBqgFAqh6jmX2TYavnmdnGmNGjoi
Dnqgxwdi4WmAeAlowmmYQ8pd/3Jh+ymeP7qkT5aiufNhDDaiyCOjJMmhflakWQqjzDmlVEp4Nsp6
YDo6PRqjZEqlXiqiaZo5azqmF6p1ZWqmqpcpBKamR1osJdqTyyA+b7p6LjKn78mltnCiPDKohOph
UYqkweaK7KCoIMKojXqlj+qniMpUWJl0Vnqpf2So/P+5qUHVqarTpKAKpVqaG6TakD6pQ6iaqmFa
oZtZp5vxqmFjqbJqG6IqpbY6O7haNbq6q0RKqyRqZZI6RmT2ncQaqmJ6BGxGNU02j0zqos16ZquK
LKByiuKzrCF6rWiWrc32J9y6PN5qreAqZeL6aqFSrvJFrVU6rOmqqqRZDtGKjix1rvI6r7OKk2CG
rHl5Ufoaq1yxannKQgYbdcjnWFLDni8aqQHbl/BKaehqFQn7fil2sU3Hc/rksKAJsUq6CYlZc9+a
Fhrbcxl7ac85lMnhaOgJaSFbMxPrO8xaPSpLsPlxskzQdv7aaDvam+M5siBTsyWhszsnY0YLADzL
sj7/67FK9KtL+Y8eRbQkkbQ9ZrUc27Dg9bKylqyWILQfQ7UCgLVIe7OfmnfIyRT7up1QC5fQ+FVi
+7NLq7Adam1Zy65ne6NdG7GIObOeOqxyy3M7i7ZfMLeWSXd5y6utmmBx67Qqim2O+7E9S390i6Yg
K2oW2bhbq7eVm5leYLgMi7dnyih/GlqaO6H6aTuBC7TGihlrW58wi7mrWbJPi2sCaqWrC3ahe3aj
6zqL+5O0y322y7kYW7eFe7e8e7Ck+7u9dbrDq7iQu7kLu1mNqZz+5rW9AbafYHM8xK+E8XOtG3SJ
67mv4q6Lob21dlsi571+Ab6ayW/BS0jXy7cA57cN/6q+S8e+feG+9QqA1usv5qsX6Bs8Sldv+ru/
Uxe+VRe/nDS/MRuf9lujFLa+B6wW/Ju2TFSxDQzA2MsaA0xsBZxuFRwXFzy53IazG9wvAZwXH7wJ
3OtCunu00wu6KCsTJcy0tcTAohqPVxmkwhrC5RbDSstzNDy4Uldv3cd+OIx/y/jAEkujEALEBEe4
xfugGwt/ynsSN8yYgqXDdMnDIhvBlSrFEkfFVzzDgivDNpzA7wtCXryfYCyzUNwfL6xCQkzDRazG
KrHFhxuYGrzDTSy7ckyp6VHHHXTHRJzGQ3zE6ZbEb0x0gSytPSy1UUTGN2fGNWzFmax5WsTG/ZvB
//+rwEdYp4vott5wqvhrwJhsxGiMxZ1Lbkj8rHU5vtUpyrsZux0XtYEKpOqwtkToyibcC4Kptvro
RHeHwsJbfMC8xMLsmMRMyqBIk8jMuj64zFzcss7Mf8U8VcdMy5xsfNbcxzswWC7QivRrutMsxLIX
zrvbtHZZzpFMUt3cu04KztMrznIXlfC8zXYweL48hOxMvfBieobYeGI7r73aXxlR0J530Oma0Cy2
0AxdeQ4NrhA9LgQ90Q5V0dd60eKS0RodiAw8wuCZqRhNeSG90SNN0sa1rqgC0in9TxzdrB6tWxId
0wg108Ra05CixGL40/UkfPRcu0Bd1Pck1FksuUb/vdTrhNSv/LBMHdXl5NRVDKdSfdWRRNVn/M1Y
3dWJpNWbPHZevdT33M7S5NP4vIXe3NKy3LEoTH3TbNKl98jTWbE8Pc503bory8zBhNZmfctJncxt
jEp53cZ7fc0CWNifDIZ23ac/4NcCfZlrXdK2/Jh/rErxe9f5/NaYPdmyWdkIA9niO9TU3NZa69mL
BpukTdQYDEwJ6GsH6H8B6L8FiEyv3Smz3dNKyMS5PUy37bqx/dK7jXXD3dfFTYB/4qu1ncO9rX/H
rQLPnaDNrQG/Ddapoa3JDalP6MfBXUzVXb3ZranTDb/jfUvf3cXdrdvlPdrB94Xu/d7wHd/yPd/0
L13f9n3f+J3f+r3f/N3f/v3fAB7gAj7gBF7gBn7gCJ7gCr7gDN7gDv7gEB7hyJQAACH5BAkHAAcA
LAAAAAD0AV4BQAP/eLrc/jDKSau9OOvNu/9gKI5kaZ5oqq5s675wLM90bd94ru987//AoHBILBqP
SBxgyWw6n9CodEqtWq/YrHbL7XqvybCYZh2Yz+i0es1uu9/wuHxOr9vv+Ly8Ou77P2V6goOEhYaH
iIlufH+Nf1iKcAGTlJWWl5iZmpucnZ6foKGio5+Ri2COqTyQpm2kr7CxsrO0tZatbFiquzmshbbA
wQEExMXGx8LHygTCzZ2GurzSZFeHztevy9rJ2sXY35XQqNPkL75u3N3G4Ozt7u+bcNHl9CvnrsHq
yPD8nl9R/YTJG0fhn5N6qe6xCciwISaDT4DpWxdsoJULEJkgdKRw/43Dj9+qCBhJsqTJkyhHYgGp
yWJGACljyhQwb6OPjmpY6gwmcqbPkyt33nrzksnPozQJ2lxVTZLQaxOZ8aSCtOpRKwxdUrXKFWXN
pTtwpnkKdaKwnl3TlsQaUOsUtXC/Mi1adIRYNGTzkkILt69MukX9Co4pNyzgl3abvtHLGBTfwZCT
HjYYubJkRjCwWP45GWIchmYb+9u6ubTp06iBKm0ROHVawJ9Fu4tKTLPr27hz615LxUTr3ZzpxpbN
jjYB28CTK1/+uneQzpSvKkb3yThxWchxG9jOvbt3K8wFex/fHXxlzD+g/0N69wwo69dhZb9Nvr75
8HDrk78PGT3YCf/qQTFcfB/N59pvuBlI2EuW+fdfBAE+MSCBDSmIGoK3WegVg+c596AL7ZlB4SXw
1aIhSQWkqOKKLLboYor4xVjZizTWqKKDH6YQ4gAjWlIiLSeOZOOQLrL3mGBH+hWkkEQ2mSKOOZ6w
4xzv0dbjJEs66aSRpPXX5WBLCqBlk1BGaQFsuHh05Vlf5hbhmxBFVpiZIqCZ5lhrTvVWcnD26YWc
q9F5pp+B3GnooYWEKeOiXl4kqI6EVoHopJTeoSijmMYV6KMAXrFbpKCGKuqopPbJKQqXNlrqqqy2
6uqpNqU605wNyBrcFGAl2ZejFdi6IK+wpufnDITCGqlvwwYLRLH/MjDL6bElOKvstNRWa+212Gar
7bbcduvtt+CGK+645JZr7rnopqvuuuy26+678MYr77z0EuHqvfjmi1i94err778Ae8hvsIVWavDB
CCdcR5kD01OwwhBHLHGlDDecxJSI5KkxP5TSarEOGE+4MSY/1hJanuIA+/FN0w0yMicl03LymilX
vLINO6Yz88s8Z0XUpjcr0fJC+VjZ89Ft/axy0CAPrSbSsgEmkdHAuCUFRvsyDaHTOUEdNV1TRyWQ
0jYvUJfWWz9MtNcO6aopW3lZvZ6qAvObM9ttt5lgFT6fwt/eS9N7N96TiK2nFMrBzTHZeqfm8Q3+
Jqb204T34zZ+//pmStLjOOsruaROVW554zFmrvlldbPm6af5ijybPhtffvrs4XGO7N8Zth46Pzv3
KDvtwANne7QcZmondaJ3YpyvwTc/e9k1mH4r6IslrzxtzOen33e/A78996SrBT01+HJJPfLXG453
9mp9X173tLvPHe5Kpv4ooa5nEjPS7DeXEev0SwmGkGQ/QeFvd9bDzurcVDzAwQ91cwMUrto1uCvt
Tz4LjMmYiOS8DoppgzYa37cq2KMLviJLIKyRB52XwhqJ0FshQ+AmTPi1AJakhTQyH+I6tCcJdg+H
L3pht2JYPU/QUDQoBCKLdBiFBoXPfz9UIouEeD/hUCqBnHigE/8DFiEfFnBcxzsUFjehRR5yUT1e
nOAQ7zWxNt6pfyvE1PAIlsHcnfGOeMzjqyhYxwPp8Y+ADOSb3AXHzQGNAYU0CRX7UMbpXW1QNuTK
HFeWSAg+slORhGIUcvXErgQOApWc5MekZY5kPYuUIUAl2hBpysy00oCqBMQrV0nLWtrylrjMpS53
ycte+vKXwAymMIdJzGIa85jITKYyl8nMZjrzmdCMpjSnSU1YCvKa2MxaNRmZzW56kwvbfMQ3x0lO
NYZTDOVMpzqXcM5lnc+N8IynPC31xXaqYHLzzKc+97lIe3oAn/sMqEAl1k9/1opruBijQifRsUMa
lIiEWKhCG/r/SYMqAKIukygWKVpQaGJUEBrd6KREWc2PUs5rRyRF70ZUs3o+FKF3qFxKR7FSCrXU
nBY9KEBjSriZiqKmBLrpJXOq03eCtKdUswVQ4yPUTRLVASSUWVJDSlVKyA0KT4UqTN1TNPVV9atX
fUJWi0qF/MXCp19NXlgPMlazbVVEaYWH1JQ6VVqstQlYy8guoxrXkICNrl6txV01AkmI7PWtPOqr
O+ZqsrrOYrDsLKxBDrtTrioWSJ2U5BUYA9kspHGodkPsZU2UWasEJW6MixPdcFovvo52FI30yWnJ
0tlKWtKpDXPta0MR21ltVi+17ePbOgpG0YousJjtIQCn0Lc2/9iWiaBtrXFlutQTlnYzioNHZ4Xn
0Hfp9mjInUVvCUiF5uZCuI7rbvQ8V6fpIhV2h2vi6aRnPPWSD1+fK6sMdyuK8fqxfPO1L7HYm0r3
8he21+UugDVH0lLmK79TMOuBO+Hf9C64vhWFVCZLoy8Jf6O6xKlwHEcMXdw6WMSe1F0ReQffPKGY
xDD+FXE18Fzp4MvD2ABxDXcY4x5/1sQgaiCjwrg2Fqsjdgn2sZL/ImAIfyHARcGx9ZaH3iVbWTcz
JmsE5WjFFYeUyhu+sphNk2VW/o/BXUbfl7FX5TG7mcMuhVy+Siyg/S4UzC+uivy2E+b47bnPVSmz
lOZsY6OedP+Gjj1ajX2yZwMA+nSNfnShgdwD+vq2snA1YqJ7tuiZRDrPyvl0kh2J1ecQmtRSkHLh
Ns2zTstE1ModMax5vFqxKuuAXp6wq2UcHQYCeoD1Y20V+6TquO5agEL+7wOBvas4fwjXatZ1m+F8
Zl8vO9nBji66vksctI5mw1Jc4puXE+4VCfrWBh6Rtyk87Q+WuwDjJve7C3BuOmI6sWtadxbbPW94
xzs5/a63sdJNIX2Tkd/z/jfA5y3wU5m0a5oO73WSWG46N2GLtAZTu91d7oaf8t5Uqg6rkYjwd1vc
KGaU74+h8JOAO7tfiA15xHWsF4qH++RLwLjKa71zDTL85fb/fok1RC5xTo+a5+vMeLMzDPMoX/Gr
oDZt0m3bYGwR+U5pjXqgp75xnBM2XVdPU9aPrnGug7rqdLrxQNe+XYVD2ePDbhXb5+7crrtdwXC3
ptzpznc0HPvu4msyDNnY98L/HfApZjq1Dq9Iszv+8aQipN2RDvnKW/4wkpc0eS/P+c6fjV2M543n
R3/GcIZSvTXOe6XJzmSgn17xTHt92VIPdI6wnteUTlue0S5dzeNe21r1/aWBTw6tw/6ikx9+7Uc5
S9WZyuGx/Gfzaxn9e04/R9AiQfXRtn1UXf/Z3afx99tK/vKb//zoT7/618/+9rv//fCPv/znT//6
2//++M+///73z//++///ABiAAjiABFiABniACJiACriADNiADviAEBiBEjiBFEhJpHeBo1KBoISB
HJh9GmhmHRiCEfKBwSeCJtgZJKhlJ7iCepWCbsWCMNiCLngAMViDkzWDNGiDOghODwhyhfeDQDgH
qvdUPhiERniEaTCERFWESNiEQaiEOcWETjiFfAeFFiWFVJiFA2WFL6VfWviFR8iFzPRweDBhL8NR
y1dSMZcxZrgxaChsS7iGQ9eGKDNSgqeGWJhRdEgzdnh850SGPLWHV/KGxHeFcmgIgsiHiMJ7HnWI
v5CIPUKIuReHeXhUkGhTfSiGvASIlsU2BgczLaaIg8CIz//EiZnmiSOnUqE4iIlyh9RkiviGikUH
CzTXGE1VauQHi9Q1i9mwipHYin5oeo4YbUjziYh2ZBpzi7aWi8OID3hjjJpQi5wFjJpIWYaWB7so
jUTXDW5IjWn4igQnC9B4iaLRWebHbarIi+QoiucVjNuEjjSViuv4i35TjboEjz8lj/OIifX4jdOE
j6EwjvtIW6lViF14jXgSNto4kOVYkJMYheF4Vvq4Pn9Vh/0Ihw/webgEkFWijpXDWOy4BnOkkbfE
kQyJJRUpVR4pCuYoWf9gjV5IjCd5cEWhkL74WA6JiwWhTSUZkTP5ECk5CwJ5CS3ZKzxpSyZ5kiAp
lBMJCkX/uZMyuJE++ZOWsJTi2JSlkJPLCJWGdY9TSZWUYJUSuZKh8JSY1JW5lJRtaHy/hVoX2Wub
54/mopZmyJbZJRTBJXzI5o5g95WXaJd8A1xa2QUrt5WhdW9gGQCAWV6C+ZZblm1yWS50KW2xFpfM
1Zh1x2yahJHxMpkHtpiX6ZaZiW2Bx5fb5peQqHVeZ14imXxlZ49xF5PO+F7ICAyqOWkAkTSOyXLL
FZnk4pkEQpaOcXtLF5qLs5tPgHe+WVyIeVwLyW6V6UBSwJpqEHqiB5t6J5tF9ozPuW/RaUfGqV2D
eXG9yZnwApzxIZyfcJuoFhG6OZrEuZkG6V2ouTHq+W1K/yedudkPbYdlrngu6Hkd9wmd+Qme0/me
7cierYedEhA57dWc2ciN8cWbaHZhXGaaQfZgD4qQeAGWCgqZrfI8/+l8+LWh2nloDPmhxekqIoqh
JHovThYFxSaIKjpcLPp2y6lhJVpgEPqTNVqa99KiDKp7MGqiEWZn+/ij8rkqQpqj3qehPMqhnTiT
Spp4QYqj5jloemmje5drr1Oba1KliMcnI9oB1mlIauel7dCdQiGmY1qe83k7bnpbpTKjXSWhLhaf
b9qkWSqneqpZaSqTxXGTFDKne2qgfUo8WwqkrmKnNrkMSPadh9pjpCh+i7qkdYqk4MCmO2Gok3oh
ZcoBmv9ZO2k2m/DAqTrhqZ9KZqG6AaPKHGGXkEaGp2H6p6sqI5WaAa+6HLHaoVWFZ7Z6q1ZWb7ua
OKW6nRoFrJIqrIdKrKSJOceKonfGZpfKrFfmrNV2oUKnqVikrAVqrXuKraqFYduqpgrlrT0HrpMq
rnA5ZNEKcVSFrhSqrp86pEQaoriZatyaQPKanLoxa+m6QgA7r6/5kL1warJVn0NJctVqFQPrrzD2
sE6gczrJMha6oCcKr6A4oIXqmn4hseQZsX8Gmoa5ehf7ezK6r5SwsI1xpgIAsignsvLTsNdpsEJz
snsppae4sagaYh7bFzCbczEWtDCRchVrslfannVmriT/gpW+87PaM7OqOhhES7ElOxdJm7A9yrOE
anQ0ixRVO7QjG6wq4aS8AG2meow9u2Nkq7RcAKcBm7PtaplXm50RUmwsyxguW6xG+62qka0Fe7Sx
ebcq21d7+6ys+muIi6lfN3DEVrjGBrWM+pjKdl18u3WJ+h9oi6yfKbmMuwVwS7AYS7kgWrdp5yeO
SlWHC7iVu6yNx7qly1ZB9yapG1KrO6766bevi7uBa7pmsrnSulu3O7cWdm2wu6KCC6D1qRd5a3NS
RK+C4XKZO0LLmxfNW3IVB719Ib1x2nQ6G4sl5LQ0+Tv9pr3b+3PTO3hbq27iqwnOq0TmCxfca7PM
+b0a/3O94JZw8ZsW85u8c1m9ZIG/5Ku/+8sV/eu7vwnATyHAnVS++QqxlCe0hTmxPnHAsrsuuriN
Xcu2rstxN/fAFDzBRSvCMSsTFoxXfFSJwdu0HMsS7wtEXme1IRzBIWvC6Nu94JLBM7fBDDvAJgfC
Ndy7MyzEoosSJ9y46qLD6bO2DPHCOBTDfTvEdBu3JnHEkQV6zci5+tO+Loy9H+y2JUzEYTzFRXwS
VgwAmfe9MrfEPNyyXvy8QDzGsRvEZAzBPvdu9lp8WbzCPsLFIOHELQTFIizDZVzFN0y/09KrdtCR
TOzGHZy4o0fCV3yaTjcpY/fI1BbJNIzE/1vJiHLJuv97GhgoyWicxO+aUFDXtl5ndqSMwafcCqBM
xcXrea1syp4sRqmMydh1gbXcl7dsKLFcyLPceb3MLYEKhm3ksuCaq4tHeMjsRspsrcycyM78zBMT
zcw6zejWqNaczJ5bwMqXvmvEzd1MUN8Mzijrv5JZzeWsMNgsrNo8u6vSzub8tehcs+JszOxMzwfz
zrcazxsxvDs40OSUxqoczgSd0Nlk0Lq8yQr90KWXwm4K0RR9TQwdyoRc0Rr9Lxcty5m80SANMB0t
zB8d0iYtjDRrO7SXz9zU0HE8yWdpqwDtyimNeue8u4isCiSLwCAo060qL7LnbCuNw7bn0mAM0w16
03TQytOxp9QqrdRoStSNsNMXHNNGnc5MHTRB3adDndMJ0bZ+uNVSbTFijchdrc7SQNUobJQ17aK9
BycD9nwfJ9cxCh2bOH5PCtfQh9eWqtdeSdcnNkh7DdhRKth/7deuRNi/G366ytda7diKith2O4KR
bdhpCdnah9kI4YFGGiB3rdgswNixItp5Jdk9Cdr2oNn1wNmFTdk4+NqwHduyPdu0Xdu2fdu4ndu6
vdu83du+/dvAHdzCPdzEXdzGfdzIndzKvdzM3dzO/dzQHd3SrQIJAAAh+QQJBwAHACwAAAAA9AFe
AUAD/3i63P4wykmrvTjrzbv/YCiOZGmeaKqubOu+cCzPdG3feK7vfO//wKBwSCwaj0gcYMlsOp/Q
qHRKrVqv2Kx2y+16r8mwmGYdmM/otHrNbrvf8Lh8Tq/b7/i8vDru+z9leoKDhIWGh4iJbnx/jX9Y
inABk5SVlpeYmZqbnJ2en6ChoqOfkYtgjqk8kKZtpK+wsbKztLWWrWxYqrs5rIW2wMEBBMTFxsfC
x8oEws2dhrq80mRXh87Xr8vatl9R2LPQqBXdTtPSvm7J2srf7bXkT+7tcNHj8EvmvOiuwevs8gAx
VRFAsKDBgwgTEsQScBS9e0wUSpwooF4+IfvYNNzIsf/SQIogETLs5A9ZsIcQAYRcWVHcxSAZ13Sc
2fAjy5sgrcxEaROnz4MWX/6IqYamUXc9fypdWGXnGyxLowYVk1IG0TRHAZYkJixp1K8KU4oFS1bi
1DBVY1xFk1XeVmbBvJYtKzbl3LsFz+qAOlfvhrVn2gqOSwWv4cOIE/v0q+TKXcYZAJsZTJmWXMWY
M2sGC9kGX7p178WZubWyR8ebU6tevZoRxsusE0oeYJr0WwKfUxvYzbu3byux8foe3hs4XtcwYQc3
OLt2x9u4UasmTt34crLUiVvvS8VRXe6B3oCC7hxY7s12W0vPCdEw8j7fQYdP94l8+Xfr0bdXv51i
+sf/3QmlQWhUjHafaeclVMCCDDbo4IMQLnjdhJhFaOGFDL4noD0ERmHggZQliBCGJELIUn9kKaeU
iCOW6OKCGm7oQnMgbmIffii2+GKJJ6r4k4+L5afQji7GKCMLNNaYyY0BAYlYh1B2CKAVR/ow2xzj
3aZkLE4eFuWXY4FnZJUm1IWLRlsaxSKFbOpHJZk7mHlmUWnStGabeCbWGZwjdJnSnIAGKugZYBZq
qFh89uAnRIM26qgih0YqaReJtnDnk5NmqummnBLI3puVGnGpl52WauqpqELx6ZihWinkUnuOGuQU
R3bZY4Ac2uqfS62+digJmva6gKQzECvsEMaKEOyx/8nC0Oyx0EYr7bTUVmvttdhmq+223Hbr7bfg
hivuuOSWa+656Kar7rrstuvuu/DGK2+ZqdZr7733zEsuvvz26y+u+k4736MEF2zwwU8BHHCVAyPs
8MMQA8rqwlRV02idGA/W6J4UN9awHhmLwmQtpYVMSTigdpzcx3mYnOVb6vjj8iQoT6yyx1V8aMvI
M79SV888KSxBWjfnWqAk/WjZsyw/zxw0rRYQXfQESS4dYmHHXeEJz+Ak/F+KvAZctdWC6TrlFFzL
8rQUsXHM7thkZ2W2mFI4dUqOmbm9Ltxxq4l1nszdCzhQYaMFkVUWi9f3kiWb9/fgLdkLeV6F7/Uq
rP+V/5U4fYvL/fjkoE+ut1qXr5j5gJvz0/lRc4fuumajO1v6j/fq3FDjCM7++u68yyb0C7LeWq/t
AeFeWfC9J++6zTizLXrqaK4u8m3Iz5XdcHgnf/1vrU/E/A3ZU8i39JxAV7188LCG/NfoR+FdmGCz
jFV9SpN/fvzp87foPe79bjj7UoGeTOgHM/IFYFRE4pHyeJdAEn2PYZEingEtozsBNBBDC2TgBS30
wG+Nb0tp81kFN2gh4TnvbFHoX/gKQsIIddBbH1RSCEmBwBY+yIQpzNoUVAgkGz7ohd26khxeZrzO
dY8z/yoUCqHWMSFKkHEFtN/nVJNEJdKNieiS06D/JsiNCmYwebHLolgcxUUcHfGLgAvjtvYHj4i5
0WFVjGMW3HU/JMrxjnjk16r8NzUH1PEreQykIE+1Ryz2MQJ/9F3KEOlFnADRD2dUpCGH1siQqHFh
iRTJ6YZVSRw+oVZTtOMkGbnCm1xSbJECVqag9awZpfKQVHulslbJLFnK7lewzKUud8nLXvryl8AM
pjCHScxiGvOYyEymMpfJzGY685nQjKY0p0nNalrzmtjMZjMHyc1uIkqb7/OmOMe5BXCGk5zoTKf7
zPkIdbrznQBgp6+m8MZ62vOed3ikPGd5NHz6858AHYA+9wkC+QX0oAh91EAJ+gAnGqKMEN3ExjbJ
/9ADOPQXEc3oLQZ1ymtelBAaDSnNOEpRhn50ECIN6UQXWVE/CrBlBpxhNkoCtEJ01JonjZ70ZEqK
ImKsZnxsqUVfiocJ8nQUPq0TUEcpVAXkdIDkO+onmlabpUrhAlLzZQxnGsWUUtVka1un0bohzK32
tH5eFUtN7/bArPbSrEhFq0i/GrKwQgGrhwsmXFPKtFACUmskkSst7OqFJV5VZXvlKywiqUmdBLar
tSBsFwwrVoolVrE09GsAHVs+wXaNrfy74mGbSFS2YNYZjDUdFezWhkxijqX6uuxpQ5Fa2q22I5Jt
QttK2i7Zznaqms1bU3DrtdqakrdvK21gfkuYHf8Gh7MbyW1E1IfclcEDcQZdrlGTyqXgik9woLtp
88hKuuxOhouQpaBznye58FYXCG5FknLPu12aNveEkMNX6MQLvrzeMmdIq6/M7ptDNBoYU7C1XCmP
m2DNmZc2zHXceg9MYdFWVlGdLORoCzpfCEfYjAWusIj/2uDx4leUQY1Mhz/cxQmP+MWzWugKXEs4
ez3RHdz13IJhzGMEb7i8xsXXjduRY9ZluMdIRvFdqbHjlQg5wBspst+anOQqb/bH//WuI2sH5dvZ
tzY09sn2imPc4IyZN1R2cop7UWbKQmHIGTXfkcFy5t2kGXJ1NsCdNVwOZLXZwm/uMl/lvOev5Ln/
0Hk6dJtlbKk/t4+egk4poR09EUVr+XWWdjH+LjwUSitZCnCO6KQvrcP8UbGSAPx0n9uZatUCWHFb
82zfwrzl0J66ya2+MqcrlusY95Nzj5Vyz2jNYFOnZn37cTMTzmlrVQca1sH+svSI7cnC6o/UkTP2
oz8pLUmF+sM19CGDrLwccTeI0dPwdqRZfJpSmnvc5I7Nu2G0ZjipG9rsvkS4zR1vec8b3dXybWWk
Clx3z7vaTuBhiLe98CH9u97hEjhlCO6JfYsb4botdcOd/YSQzLsAAKeWxAdD8U5Y3IcYn66yl6Bw
TSvo4Uw118gFU3JOnNyGKWe5xlW18xM7/N0h/xdYh7FEQGHPzNO7gie1+Quup9Ip1uldHNK9p/Sl
vzddTp8f1I3usqmbpepz/nqJIz7GhxZd2qvbCrX73fNdz0uLgsq3vsPOduGO/V1wD5TcLbH2ujN8
yaT908X2Tom++53jq94btgWf0MbrAez46iMbyeH4yuMB8veS/OIZZfnOzwHz9tK8y8PCec+b/vSD
CLrovV5j0Lt+nHwGPDANn/TX256bsee2Xun+2tv7Po+5T/wvaU/13xtfjsFvwjCJHSvekz7m6cZ2
zuMZNecH7u6HZH5J+656P49e125vqPUph/3VS7/29eY+xFOx6LaOnynln1orPbAsYc1fvrjcff/+
Q1D/Xt1/xrY0fAEICLRkfwPYaPvXVAq4gAzYgA74gBAYgRI4gRRYgRZ4gRiYgRq4gRzYgR74gSAY
giI4giRYgiZ4giiYgiq4gizYgi74gjAYgzI4gzRYgzZ4gziYgzq4g2V1fD5YKjxIAT84hJsShLFE
hEiYgEbYAEnYhIWyhKTkhFIYGlAIAVN4hXVRheKHhVxIDlroUl0YhpSigw+GemZ4hmjQfRFYhmjY
hqenhhDIhm44h5UHhw8oh3SYhwdlhw6Ih3r4h/jEhw3oh4BYiG4kiNmUdXdAeGSzUoiYTIpoB4xo
NY64fvIUiXUwiUtTidDXUphIB5q4VoHCdNL/9IlEF4pgRVLxd4lDhwio6DKciGULaIpD9Ip1pYqP
iEy0+G22WFW4aIns9IkChna0wHXlYVXhJ1TCGFOyFgvG6BzIKHuD2Iq86DI1F23LIIqCQIrRtIxR
1Yyw8Iy+SAjcCE3euFPgyFXroI2Pd3XgdI5pl45nNWCwaFPuqE3w2DnXmAl0RRnRqHtC6F8C+GAx
I45Sp1ZOU1zrF1+7NHNQZJCzhpD1CFqdyAAMqUsOiQn7GDf9+FMKWZGclC/692rARjLyOEEdqVQf
KYtWKJBatWJJE3UZlZJpIl34UH0iOXswCQwbSTY0uSU2SX1j9QU9SJAxCZFp0n7QVRlBaXWr/6gu
GXkJPakkSjlc49ha8LNpINlbO9mLm1CVt9VZMhkLTfl+8JeLIteVXsmP52dJgCWWSKkJZYlokgSM
WKeWazl339d7YWkjJykKcxlk9yhzeJmX7eZziNcEW0eMn4WVveZrW5lcRmmYAtGWyccEi0mPthCY
lil2aCl0k0mZfNeZWgkFU4kJQblbT3mXoSmahUeaiQkArJULZllsdilGremaBwSbJDYFs7kGxFd8
n9lthSmarFeX3kBcFImYdjec0RKV7HacjeWbyumY0kl+zslKxUmZ13l91MkRqUldq4mbJKk6ujkJ
3YmddVOdtEmXfBmZipebrpme05cp+zWYcf/ikghYnjr1jYzZV3v5Xe3FXtlZAheZAtAJImO5WLyJ
GfrlXuOJYTkJPNvZNwsqQgE6IQ9KoLcpodcFZL9mnv6pmSDGcxyKKvcZoaugn/jHn1DFjP/ZXRl6
HRuaX/ipYB+aZZCGb+gYoww6o8tRo4NTjtiVoxQqn/oYl2wJpM8FXifKkkhwoCiQoAdyoZnFpIf3
RUTKT4KpomDook93nruJpVm6QFvKf7XJdFQaYfRZprBzoyrglCG3pszVpm7qoHCKoGmap05VoXlp
p3eqJ3x6AnLaoVsIplonpoAaqKTipQDonq1nqF8aov2pm4vKqG0njSsKqd6ZKtVYkNvgHMH/ialo
VKBDxanqiSqfepShCma1SaoiZqqFqqrrhmM+amSoCqsjJqt7amO1SmS3OmWXqqt4wqu5mm2e+qvf
oKTfMKrECkaSSoDH+mQ8qhXBaiev+qylGq3016vDo6zYwKzY4KwIkWnMqT11dqxnmYyP2qXfWq1u
ca0zQa4HYa4bpzz2aqKAtmzeR6a3hqimdVqj5q90lq7DGhX52nGZyq/zdK4CCrDahVkD67CIkbAJ
d2AWm3ErJ5TWRbE06qeiRj3ZChIZq3JoVLI6t7FEoK4bywSrOqIlQa8GgbIqgbEG26DTya5shrM+
RqkvqlgTe6+KQbMURrQLe5MNK7QP67Nh/wq0IsuyJHuzBAs4Rquy/eqxQQqyEBW0+uom2vamuJZs
+4q0kJSVvcm0iQqX8tp1I4t+3SCek/e2R8ux8GG24Adq4EoJpwkiMpuqXwC3m9dsf8ewZfuYtrmj
JemXVjpsbSuc5AC4TGq4kAmQdSu5aoakGvmXbAu1bvu31xa5Yju4ZFu5oXu3HpK3k7C3B9K36yq3
/xq4X5uYzBa7roa2AYuN62hArIusrntsqFa6stsI8VGapwuvmbu4RzdCB6etYPFxpqpKluuWmCuV
mmsyN9dCzNu8MAellXJviUt410tC2fsVzsut0XcoL4tZ4btB4xsV5QufMuK9IsqI63tB7f+7FO/L
vYkiv5UKvsr7bverFPmrs/YWQahbp//LbwHsEwOsqf5nwMabb/XbQNPXcgrbsik7EQ1MuawJsfQV
MqqLngl8cZc7tY7btcSLwi8HdOYbcFp7HyE8pj7ycRU8txastCy0vQRMdtNbIzE8wQlUwxt7wyqs
I+b2vEJBp1FWvSY3wihXwlhba1F8uFi7wcLXwbYbsRnzw06Mc1CMw7Y1xfVZsxRhxcrHlT2soExs
c12MvV9cxKarsWMrxxJhxoQLlS9cHjEMCm06hBhMt6hEiPOrCXvsqiYcm5D3xzeTj4S8xhjTxz+o
yIhFjQdcyLlzyGf7e5IceB4MimdHokb/xLNhbHybbFmUHMGWYMnHI8qTe3uljEmn/L0PubbWy8pS
TMpzfMeB3MmniLutOm22TMWunMujS5iMB1KfnLswuwy7S6xnmi15Byia2My6+szYEs1zMs2Nu8DW
fC3YfCbazLkLzLv6i3dlt0WTSM2w2s3W8s24EM4HW6bs7MLHrHfpvM0BPM98ErdfYIh1KIbfVDT8
7AX+7HgAHdCLDLuUV9AJddBSKi8D3QUM3dAOPaGTDLqiMdF7WNFGetFTXM8aHYgc7YXyp9DdENIo
XU9I7NFg7LcEndIw/TArjcWYPMojfdMVpM/jsrs43dOjotPiwtM+PdQZCtQ83J1EndRw/+zSDsxL
Qq3UUE3GntnC8PLUUZ3Ul1nMb4XPyHnVPZ3VgLzVudp8LDvTRyrGb8zBUSjKRt10ZknWYNnU5hDX
cj2pNc3Uau3Ub7192WrWrnTXkbrDp1pbbe1Be4196ge/rIbW0huZ2ueob3fYzJPY5bwLdJ3Xh8rW
g2rOY83XZU3VRXDZV7zWgE3Ogi3QB9gB/dcq/xenqd2Qr+1grU0msz2lsZ1LtY0Bqx0quU0vSijW
hgK9vb0hwy3cT1iUwd0nBfjAv92ixz2Sz42mk6Kdzd2u0T2Qyc2ltw1B1e3a3Y3b263by83a4e3b
2f2F6J3e6r3e7N3e7v3e8B3f8j3f9CZd3/Z93/id3/q93/zd3/793wAe4AI+4ARe4AZ+4Aie4Aq+
4NqUAAA7
------sinikael-?=_5-14507714776260.4520441491622478--
------sinikael-?=_2-14507714776260.4520441491622478--
------sinikael-?=_1-14507714776260.4520441491622478
Content-Type: text/plain; charset=utf-8; name="notes
=?UTF-8?Q?=F0=9F=95=B6=2Etxt?="
Content-Disposition: attachment;
filename*0*=utf-8''notes%20%F0%9F%95%B6.txt
Content-Transfer-Encoding: quoted-printable
Some notes about this e-mail =F0=9F=90=B1
------sinikael-?=_1-14507714776260.4520441491622478--

View file

@ -0,0 +1,599 @@
Subject: Ryan Finnie's MIME Torture Test v1.0
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-qYxqvD9rbH0PNeExagh1"
Message-Id: <1066976914.4721.5.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:28:34 -0700
--=-qYxqvD9rbH0PNeExagh1
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
Welcome to Ryan Finnie's MIME torture test. This message was designed
to introduce a couple of the newer features of MIME-aware MUAs, features
that have come around since the days of the original MIME torture test.
Just to be clear, this message SUPPLEMENTS the original torture test,
not replaces it. The original test is still very much valid these days,
and new MUAs should strive to first pass the original test, then this
one.
By the way, the message/rfc822 parts have Content-Descriptions
containing Futurama quotes. Bonus points if the MUA display these
somewhere.
Have fun!
Ryan Finnie
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: I'll be whatever I wanna do. --Fry
Content-Type: message/rfc822
Subject: plain jane message
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Message-Id: <1066973156.4264.42.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:25:56 -0700
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
Subject: plain jane message
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: text/plain
Message-Id: <1066973156.4264.42.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:25:56 -0700
Content-Transfer-Encoding: 7bit
This is a plain text/plain message. Nothing fancy here...
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Would you kindly shut your noise-hole? --Bender
Content-Type: message/rfc822
Subject: messages inside messages inside...
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-9Brg7LoMERBrIDtMRose"
Message-Id: <1066976111.4263.74.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:15:11 -0700
--=-9Brg7LoMERBrIDtMRose
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
While a message/rfc822 part inside another message/rfc822 part in a
message isn't too strange, 200 iterations of that would be. The MUA
should have some sense when to stop looping through.
--=-9Brg7LoMERBrIDtMRose
Content-Disposition: inline
Content-Description: At the risk of sounding negative, no. --Leela
Content-Type: message/rfc822
Subject: the original message
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-XFYecI7w+0shpolXq8bb"
Message-Id: <1066975745.4263.70.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:09:05 -0700
--=-XFYecI7w+0shpolXq8bb
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
by this point, I should be the 3rd layer deep!
I also have an attachment.
--=-XFYecI7w+0shpolXq8bb
Content-Disposition: attachment; filename=foo.gz
Content-Transfer-Encoding: base64
Content-Type: application/x-gzip; NAME=foo.gz
H4sIAOHBmD8AA4vML1XPyVHISy1LLVJIy8xLUchNVeQCAHbe764WAAAA
--=-XFYecI7w+0shpolXq8bb--
--=-9Brg7LoMERBrIDtMRose--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Dirt doesn't need luck! --Professor
Content-Type: message/rfc822
Subject: this message JUST contains an attachment
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Disposition: attachment; filename=blah.gz
Content-Transfer-Encoding: base64
Content-Description: Attachment has identical content to above foo.gz
Message-Id: <1066974048.4264.62.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:40:49 -0700
Content-Type: application/x-gzip; NAME=blah.gz
SubjectthismessageJUSTcontainsanattachmentFromRyanFinnierfinniedomaindomTobo
bdomaindomContentDispositionattachmentfilenameAblahgzContentTypeapplication/
xgzipnameAblahgzContentTransferEncodingbase64ContentDescriptionAttachmenthas
identicalcontenttoabovefoogzMessageId1066974048426462camellocalhostMimeVersi
on10Date23Oct20032240490700H4sIAOHBmD8AA4vML1XPyVHISy1LLVJIy8xLUchNVeQCAHbe7
64WA
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Hold still, I don't have good depth perception! --Leela
Content-Type: message/rfc822
Subject: Attachment filename vs. name
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-1066975756jd02"
Message-Id: <1066975756.4263.70.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:09:16 -0700
--=-1066975756jd02
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
In this message's attachment, the Content-Disposition has a
filename of blah1.gz, while the Content-Type has a name of
blah2.gz. What should be done? Well, since this is an attachment
(as indicated in the Content-Disposition), the MUA should
suggest a filename of blah1.gz. The MUA *COULD* find a way to
represent the name of blah2.gz somewhere else, it's not needed.
--=-1066975756jd02
Content-Disposition: attachment; filename=blah1.gz
Content-Transfer-Encoding: base64
Content-Description: filename is blah1.gz, name is blah2.gz
Content-Type: application/x-gzip; NAME=blah2.gz
H4sIAOHBmD8AA4vML1XPyVHISy1LLVJIy8xLUchNVeQCAHbe764WAAAA
--=-1066975756jd02--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Hello little man. I WILL DESTROY YOU! --Moro
Content-Type: message/rfc822
Subject: No filename? No problem!
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-1066975756jd03"
Message-Id: <1066975761.4263.70.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 23:09:21 -0700
--=-1066975756jd03
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
When searching for a suitable name to suggest for a filename,
the MUA should probably follow this order. First, look for
Content-Disposition's filename attribute. If that is missing,
look for Content-Type's file attribute. If that is also missing,
I would recomment taking the Content-Description, stripping off
any characters that cannot be used in a filename, and suggesting
that.
If none of those fields are available, the MUA could just make
up a random filename. SOMETHING is better than nothing.
--=-1066975756jd03
Content-Disposition: attachment
Content-Transfer-Encoding: base64
Content-Description: I'm getting sick of witty things to say
Content-Type: application/x-gzip
H4sIAOHBmD8AA4vML1XPyVHISy1LLVJIy8xLUchNVeQCAHbe764WAAAA
--=-1066975756jd03--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Friends! Help! A guinea pig tricked me! --Zoidberg
Content-Type: message/rfc822
Subject: html and text, both inline
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-ZCKMfHzvHMyK1iBu4kff"
Message-Id: <1066974044.4264.62.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:40:45 -0700
--=-ZCKMfHzvHMyK1iBu4kff
Content-Type: text/html; CHARSET=utf-8
Content-Transfer-Encoding: 8bit
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 TRANSITIONAL//EN">
<HTML>
<HEAD>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; CHARSET=UTF-8">
<META NAME="GENERATOR" CONTENT="GtkHTML/1.1.10">
</HEAD>
<BODY>
<FONT COLOR="#f8cc00">This is the HTML part.</FONT><BR>
It should be displayed inline.
</BODY>
</HTML>
--=-ZCKMfHzvHMyK1iBu4kff
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
This is the text part.
It should ALSO be displayed inline.
--=-ZCKMfHzvHMyK1iBu4kff--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Smeesh! --Amy
Content-Type: message/rfc822
Subject: text and text, both inline
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-pNc4wtlOIxs8RcX7H/AK"
Message-Id: <1066974089.4265.64.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:41:29 -0700
--=-pNc4wtlOIxs8RcX7H/AK
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
This is the first text part.
It should be displayed inline.
--=-pNc4wtlOIxs8RcX7H/AK
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
This is the second text part.
It should also be displayed inline.
--=-pNc4wtlOIxs8RcX7H/AK--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: That's not a cigar. Uh... and it's not mine. --Hermes
Content-Type: message/rfc822
Subject: HTML and... HTML?
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/mixed; boundary="=-zxh/IezwzZITiphpcbJZ"
Message-Id: <1066973957.4263.59.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:39:17 -0700
--=-zxh/IezwzZITiphpcbJZ
Content-Type: text/html; CHARSET=utf-8
Content-Transfer-Encoding: 8bit
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 TRANSITIONAL//EN">
<HTML>
<HEAD>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; CHARSET=UTF-8">
<META NAME="GENERATOR" CONTENT="GtkHTML/1.1.10">
</HEAD>
<BODY>
<B>Bold!!!</B><BR>
<BR>
What do we have here... This message is an HTML message. Also attached
is an HTML FILE. Both of these are in a multipart/mixed part.<BR>
<BR>
Now, the first HTML part (what you're reading now) should be displayed
if the MUA is HTML-capable. If it is not, the MUA could possibly offer
this part up as an attachment to download, seeing as how no plaintext
part is offered as an alternative.<BR>
<BR>
However, the second HTML part is listed with a disposition as
attachment. Therefore, it should be offered as an attachment, not
displayed inline.
</BODY>
</HTML>
--=-zxh/IezwzZITiphpcbJZ
Content-Disposition: attachment; filename=htmlfile.html
Content-Type: text/html; NAME=htmlfile.html; CHARSET=UTF-8
Content-Transfer-Encoding: 8bit
<html>
<head><title>This is an Attachment</title></head>
<body>
<p>The title says it all...</p>
</body>
</html>
--=-zxh/IezwzZITiphpcbJZ--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: The spirit is willing, but the flesh is spongy, and
bruised. --Zapp
Content-Type: message/rfc822
Subject: smiley!
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/signed; micalg=pgp-sha1; protocol="application/pgp-signature"; boundary="=-vH3FQO9a8icUn1ROCoAi"
Message-Id: <1066972996.4264.39.camel@localhost>
Mime-Version: 1.0
Date: 23 Oct 2003 22:23:16 -0700
--=-vH3FQO9a8icUn1ROCoAi
Content-Type: multipart/mixed; boundary="=-CgV5jm9HAY9VbUlAuneA"
--=-CgV5jm9HAY9VbUlAuneA
Content-Type: multipart/related; type="multipart/alternative";
boundary="=-GpwozF9CQ7NdF+fd+vMG"
--=-GpwozF9CQ7NdF+fd+vMG
Content-Type: multipart/alternative; boundary="=-dHujWM/Xizz57x/JOmDF"
--=-dHujWM/Xizz57x/JOmDF
Content-Type: text/plain
Content-Transfer-Encoding: quoted-printable
If this sentence is red, you are viewing the HTML part.
Wow, what a complicated message. This message is laid out as so:
multipart/signed
| multipart/mixed
| | mutipart/related
| | | multipart/alternative
| | | | text/plain
| | | | text/html
| | | image/png (smiley)
| | image/gif (dot)
| application/pgp-signature
:)
A smiley face should be embedded into the HTML part above (if you are
viewing the HTML part), while the red square dot should be attached, not
displayed inline. Additionally, this whole message is PGP signed.
This message introduces a few tricks that the MUA should cope with.=20
First of all, the related / alternative combination doesn't make much
sense. Here's the current setup in pseudo-code:
relationship between (alternative: HTML or text part) and PNG part
Why would the text part be related in anyway to the PNG? Instead, the
correct and more logical way to do things would be:
alternative: (relationship between HTML and PNG parts) or text part
However, many MUAs compose a message using the first method, so this
should be taken care of when parsing the message.
Additionally, notice that the inline image has a disposition of
"attachment". Despite this being in there, the smiley should be
embedded inline in the HTML part, not offered as an attachment.=20
Conversely, the GIF image should be offered as an attachment, not
displayed inline.
If the MUA is not PGP capable, at the very least it should recognize
multipart/signed the same as multipart/mixed, and offer the
application/pgp-signature part as an attachment.
--=-dHujWM/Xizz57x/JOmDF
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 TRANSITIONAL//EN">
<HTML>
<HEAD>
<META HTTP-EQUIV=3D"Content-Type" CONTENT=3D"text/html; CHARSET=3DUTF-8">
<META NAME=3D"GENERATOR" CONTENT=3D"GtkHTML/1.1.10">
</HEAD>
<BODY>
<FONT COLOR=3D"#f80000">If this sentence is red, you are viewing the HTML p=
art.</FONT><BR>
Wow, what a complicated message.  This message is laid out as so:<BR>
<BR>
multipart/signed<BR>
| multipart/mixed<BR>
| | mutipart/related<BR>
| | | multipart/alternative<BR>
| | | | text/plain<BR>
| | | | text/html<BR>
| | | image/png (smiley)<BR>
| | image/gif (dot)<BR>
| application/pgp-signature<BR>
<BR>
<IMG SRC=3D"cid:1066971953.4232.15.camel@localhost" ALIGN=3D"bottom" ALT=3D=
":)" BORDER=3D"0"><BR>
<BR>
A smiley face should be embedded into the HTML part above (if you are viewi=
ng the HTML part), while the red square dot should be attached, not display=
ed inline.  Additionally, this whole message is PGP signed.<BR>
<BR>
This message introduces a few tricks that the MUA should cope with.  F=
irst of all, the related / alternative combination doesn't make much sense.=
  Here's the current setup in pseudo-code:<BR>
<BR>
<I>relationship between (alternative: HTML or text part) and PNG part</I><B=
R>
<BR>
Why would the text part be related in anyway to the PNG?  Instead, the=
correct and more logical way to do things would be:<BR>
<BR>
alternative: (relationship between HTML and PNG parts) or text part<BR>
<BR>
However, many MUAs compose a message using the first method, so this should=
be taken care of when parsing the message.<BR>
<BR>
Additionally, notice that the inline image has a disposition of "attac=
hment".  Despite this being in there, the smiley should be embedd=
ed inline in the HTML part, not offered as an attachment.  Conversely,=
the GIF image should be offered as an attachment, not displayed inline.<BR=
>
<BR>
If the MUA is not PGP capable, at the very least it should recognize multip=
art/signed the same as multipart/mixed, and offer the application/pgp-signa=
ture part as an attachment.
</BODY>
</HTML>
--=-dHujWM/Xizz57x/JOmDF--
--=-GpwozF9CQ7NdF+fd+vMG
Content-ID: <1066971953.4232.15.camel@localhost>
Content-Disposition: attachment; filename=smiley-3.png
Content-Type: image/png; name=smiley-3.png
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAC+klEQVR42n2TbUjVdxTHP/+H69Xd
a2VWlFe69rzthZJUoxeNOWoFGxEhYRRFmZSVW2u9ab2KejWE1qDNBkEQhS82VoiaZkVPmoWaKNM5
mA+opbd771//997//T/+epHBarEPHA6Hc84XDnwP/JcwcBS4AVgzcR04ONN7C+md+pcPCz44dPLA
arZs/gg1UABuGkvvp7X1Iad+itE/YtUAle8TuH26sujzqq/LkJQsnOQQVmIASVJQMhehZORiJwc5
d76FH2pf3gY2Aigzy7+eObqmtOqbXbjGGHZqCM+eQpJ9AHhWFCc5CAjWf1KAkppc+qg3vRCol4Fw
0aqcisOVW3HTE7hmBElSKD/5GFkNMhH1KDvegST78CwNSfZxeM88VuYrh4CwAuxqvxL6MnPuWiy9
H1kNUPH9fZofDKPpHn8/z+Z6Yw8JK5stX5VhRO6h+OfiV3WaHxtPVKAwmF+KqXUDMkgqZ0+UoKcE
P57/GXOqh46ODqrPXUQfufb6YOGxJOQD2CaHQnnlAJ4zDXggHBYvK6ap6Rau+RIz1k7djd+YHrqM
pXUC4KQnWTRPAdiuRqNRkFQG/omRNJOsKVQw408xtS4QDsI10AaqEY6O8Fzq70fJy3XI8gsA5HTa
rBdOkvwFKj39EWrr/sJzEnj29OvsphGugfBsLlwbZnjcYN36LxiLuADtMtCUetFAcE4ee8s+pbHV
YtOemwhHx3MSaPEY3X9OUnqsk5a2OMeP7KC3t4u+3gRALUC4cEW2eN62Q4ze3SAiz74TDxvOiI+X
BcTsoCoyfJKYn6OKmrMbxGRnlXhyJSSqv80Vq0KSAFa+ceKl0wcK9lfsW42TGsE/pxhfcDmKfz6e
FUPg4iRH6Ov6g9EJh1t341xusWuAyn9b+c7BrbklJ8oDZGTOQpL9ePY08SmDpCEwbcHwuE370yku
Nlj3gM/e90yXliyU9+8sCVJYlEUgU8IwBZruMThm83uzxsAYV4Hd/A9h4BjQBthAFOgDLgDF7w6/
ArI6YJ0eTQeGAAAAAElFTkSuQmCC
--=-GpwozF9CQ7NdF+fd+vMG--
--=-CgV5jm9HAY9VbUlAuneA
Content-Disposition: attachment; filename=dot.gif
Content-Type: image/gif; name=dot.gif
Content-Transfer-Encoding: base64
R0lGODdhCgAKAKEAAAAAANUAAP///8PDwywAAAAACgAKAEACHZSPMssLKoIMYLyR1I2z3sZsE2VB
owcBqlqurloAADs=
--=-CgV5jm9HAY9VbUlAuneA--
--=-vH3FQO9a8icUn1ROCoAi
Content-Type: application/pgp-signature; name=signature.asc
Content-Description: This is a digitally signed message part
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.2.2 (GNU/Linux)
iD8DBQA/mLdEKZYQqSA+yiURAjAnAJ90G22jbX/Broy0F541R0UUbsb6zgCeJn0d
02Vq9Sv6aXE+YM0lRn3jZDc=
=uwCM
-----END PGP SIGNATURE-----
--=-vH3FQO9a8icUn1ROCoAi--
--=-qYxqvD9rbH0PNeExagh1
Content-Disposition: inline
Content-Description: Kittens give Morbo gas. --Morbo
Content-Type: message/rfc822
Subject: the PROPER way to do alternative/related
From: Ryan Finnie <rfinnie@domain.dom>
To: bob@domain.dom
Content-Type: multipart/alternative; type="multipart/alternative"; boundary="=-tyGlQ9JvB5uvPWzozI+y"
Message-Id: <1066973557.4265.51.camel@localhost>
Mime-Version: 1.0
X-Mailer: Not Evolution
Date: 23 Oct 2003 22:32:37 -0700
--=-tyGlQ9JvB5uvPWzozI+y
Content-Type: text/plain; CHARSET=US-ASCII
Content-Transfer-Encoding: 8bit
If this sentence is green, you're viewing the HTML part.
Now, this is the way that all MUAs SHOULD treat this kind of situation.
The layout is like so:
multipart/alternative
| text/plain
| multipart/related
| | text/html
| | image/gif
See? The GIF (which by the way should be inline towards the top of this
message) is related to the HTML, and that whole block is an alternative
to a text/plain part. This is the opposite of the way shown in the
previous email.
Also, the embedded image here does not have a filename. As mentioned
above, the MUA should suggest something as a filename, even here (the
user may want to save the embedded image, so a filename would be
helpful). In this case, I would recommend appending the random text
to be suggested to the user with the part's subtype, in this case
something like c20vsidlkvm.gif.
--=-tyGlQ9JvB5uvPWzozI+y
Content-Type: multipart/related; boundary="=-bFkxH1S3HVGcxi+o/5jG"
--=-bFkxH1S3HVGcxi+o/5jG
Content-Type: text/html; CHARSET=utf-8
Content-Transfer-Encoding: 8bit
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 TRANSITIONAL//EN">
<HTML>
<HEAD>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; CHARSET=UTF-8">
<META NAME="GENERATOR" CONTENT="GtkHTML/1.1.10">
</HEAD>
<BODY>
<FONT COLOR="#00fc00">If this sentence is green, you're viewing the HTML part.</FONT><BR>
<IMG SRC="cid:1066973340.4232.46.camel@localhost" ALIGN="top" ALT="" BORDER="0"><BR>
Now, this is the way that all MUAs <B>SHOULD</B> treat this kind of situation.  The layout is like so:<BR>
<BR>
multipart/alternative<BR>
| text/plain<BR>
| multipart/related<BR>
| | text/html<BR>
| | image/gif<BR>
<BR>
See?  The GIF (which by the way should be inline towards the top of this message) is related to the HTML, and that whole block is an alternative to a text/plain part.  This is the opposite of the way shown in the previous email.<BR>
<BR>
Also, the embedded image here does not have a filename.  As mentioned above, the MUA should suggest something as a filename, even here (the user may want to save the embedded image, so a filename would be helpful).  In this case, I would recommend appending the random text to be suggested to the user with the part's subtype, in this case something like c20vsidlkvm.gif.
</BODY>
</HTML>
--=-bFkxH1S3HVGcxi+o/5jG
Content-ID: <1066973340.4232.46.camel@localhost>
Content-Transfer-Encoding: base64
Content-Type: image/gif
R0lGODlhBQALAPIAAKIA/64A/8ZL/////8BS/2QDANZ//wAAACH5BAEAAAMALAAAAAAFAAsAAAMY
OBIytsIYEoiEl0lqFWgKM4zkUJzjWQwJADs=
--=-bFkxH1S3HVGcxi+o/5jG--
--=-tyGlQ9JvB5uvPWzozI+y--
--=-qYxqvD9rbH0PNeExagh1--

42
imap-core/test/fixtures/simple.eml vendored Normal file
View file

@ -0,0 +1,42 @@
Content-Type: multipart/mixed;
boundary="----sinikael-?=_1-14508625179060.6947296333964914"
From: =?UTF-8?Q?Sender_Name_=F0=9F=91=BB?= <sender@example.com>
To: =?UTF-8?Q?Receiver_Name_=F0=9F=91=A5?= <receiver@example.com>
Subject: Nodemailer is unicode friendly =?UTF-8?Q?=E2=9C=94?=
Date: Wed, 23 Dec 2015 09:21:57 +0000
Message-Id: <1450862517911-ebf3ac1e-e421bda1-4d17b820@example.com>
MIME-Version: 1.0
------sinikael-?=_1-14508625179060.6947296333964914
Content-Type: multipart/alternative;
boundary="----sinikael-?=_2-14508625179060.6947296333964914"
------sinikael-?=_2-14508625179060.6947296333964914
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
hello
------sinikael-?=_2-14508625179060.6947296333964914
Content-Type: text/html
Content-Transfer-Encoding: 7bit
<p>hello</p>
------sinikael-?=_2-14508625179060.6947296333964914--
------sinikael-?=_1-14508625179060.6947296333964914
Content-Type: text/plain; charset=utf-8; name="notes
=?UTF-8?Q?=F0=9F=95=B6=2Etxt?="
Content-Disposition: attachment;
filename*0*=utf-8''notes%20%F0%9F%95%B6.txt
Content-Transfer-Encoding: quoted-printable
Some notes about this e-mail =F0=9F=90=B1
------sinikael-?=_1-14508625179060.6947296333964914
Content-Type: image/png; name=image.png
Content-Disposition: attachment; filename=image.png
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD///+l2Z/dAAAAM0lE
QVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDeNGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQ
AAAAAElFTkSuQmCC
------sinikael-?=_1-14508625179060.6947296333964914--

164
imap-core/test/fixtures/simple.json vendored Normal file
View file

@ -0,0 +1,164 @@
{
"childNodes": [{
"childNodes": [{
"header": [
"Content-Type: text/plain",
"Content-Transfer-Encoding: 7bit"
],
"parsedHeader": {
"content-transfer-encoding": "7bit",
"content-type": {
"value": "text/plain",
"type": "text",
"subtype": "plain",
"params": {}
}
},
"body": "hello",
"multipart": false,
"boundary": false,
"lineCount": 1,
"size": 5
}, {
"header": [
"Content-Type: text/html",
"Content-Transfer-Encoding: 7bit"
],
"parsedHeader": {
"content-transfer-encoding": "7bit",
"content-type": {
"value": "text/html",
"type": "text",
"subtype": "html",
"params": {}
}
},
"body": "<p>hello</p>",
"multipart": false,
"boundary": false,
"lineCount": 1,
"size": 12
}],
"header": [
"Content-Type: multipart/alternative;\r\n boundary=\"----sinikael-?=_2-14508625179060.6947296333964914\""
],
"parsedHeader": {
"content-type": {
"value": "multipart/alternative",
"type": "multipart",
"subtype": "alternative",
"params": {
"boundary": "----sinikael-?=_2-14508625179060.6947296333964914"
},
"hasParams": true
}
},
"body": "",
"multipart": "alternative",
"boundary": "----sinikael-?=_2-14508625179060.6947296333964914",
"lineCount": 1,
"size": 0
}, {
"header": [
"Content-Type: text/plain; charset=utf-8; name=\"notes\r\n =?UTF-8?Q?=F0=9F=95=B6=2Etxt?=\"",
"Content-Disposition: attachment;\r\n filename*0*=utf-8''notes%20%F0%9F%95%B6.txt",
"Content-Transfer-Encoding: quoted-printable"
],
"parsedHeader": {
"content-transfer-encoding": "quoted-printable",
"content-disposition": {
"value": "attachment",
"type": "attachment",
"subtype": "",
"params": {
"filename": "=?UTF-8?Q?notes=20=F0=9F=95=B6.txt?="
},
"hasParams": true
},
"content-type": {
"value": "text/plain",
"type": "text",
"subtype": "plain",
"params": {
"charset": "utf-8",
"name": "notes =?UTF-8?Q?=F0=9F=95=B6=2Etxt?="
},
"hasParams": true
}
},
"body": "Some notes about this e-mail =F0=9F=90=B1",
"multipart": false,
"boundary": false,
"lineCount": 1,
"size": 41
}, {
"header": [
"Content-Type: image/png; name=image.png",
"Content-Disposition: attachment; filename=image.png",
"Content-Transfer-Encoding: base64"
],
"parsedHeader": {
"content-transfer-encoding": "base64",
"content-disposition": {
"value": "attachment",
"type": "attachment",
"subtype": "",
"params": {
"filename": "image.png"
},
"hasParams": true
},
"content-type": {
"value": "image/png",
"type": "image",
"subtype": "png",
"params": {
"name": "image.png"
},
"hasParams": true
}
},
"body": "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEUAAAD///+l2Z/dAAAAM0lE\r\nQVR4nGP4/5/h/1+G/58ZDrAz3D/McH8yw83NDDeNGe4Ug9C9zwz3gVLMDA/A6P9/AFGGFyjOXZtQ\r\nAAAAAElFTkSuQmCC",
"multipart": false,
"boundary": false,
"lineCount": 3,
"size": 172
}],
"header": [
"Content-Type: multipart/mixed;\r\n boundary=\"----sinikael-?=_1-14508625179060.6947296333964914\"",
"From: =?UTF-8?Q?Sender_Name_=F0=9F=91=BB?= <sender@example.com>",
"To: =?UTF-8?Q?Receiver_Name_=F0=9F=91=A5?= <receiver@example.com>",
"Subject: Nodemailer is unicode friendly =?UTF-8?Q?=E2=9C=94?=",
"Date: Wed, 23 Dec 2015 09:21:57 +0000",
"Message-Id: <1450862517911-ebf3ac1e-e421bda1-4d17b820@example.com>",
"MIME-Version: 1.0"
],
"parsedHeader": {
"mime-version": "1.0",
"message-id": "<1450862517911-ebf3ac1e-e421bda1-4d17b820@example.com>",
"date": "Wed, 23 Dec 2015 09:21:57 +0000",
"subject": "Nodemailer is unicode friendly =?UTF-8?Q?=E2=9C=94?=",
"to": [{
"address": "receiver@example.com",
"name": "=?UTF-8?Q?Receiver_Name_=F0=9F=91=A5?="
}],
"from": [{
"address": "sender@example.com",
"name": "=?UTF-8?Q?Sender_Name_=F0=9F=91=BB?="
}],
"content-type": {
"value": "multipart/mixed",
"type": "multipart",
"subtype": "mixed",
"params": {
"boundary": "----sinikael-?=_1-14508625179060.6947296333964914"
},
"hasParams": true
}
},
"body": "",
"multipart": "mixed",
"boundary": "----sinikael-?=_1-14508625179060.6947296333964914",
"lineCount": 1,
"size": 0
}

View file

@ -0,0 +1,644 @@
/*eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */
'use strict';
let chai = require('chai');
let imapHandler = require('../lib/handler/imap-handler');
let PassThrough = require('stream').PassThrough;
let crypto = require('crypto');
let expect = chai.expect;
chai.config.includeStack = true;
describe('IMAP Command Compile Stream', function () {
describe('#compile', function () {
it('should compile correctly', function (done) {
let command = '* FETCH (ENVELOPE ("Mon, 2 Sep 2013 05:30:13 -0700 (PDT)" NIL ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "tr.ee")) NIL NIL NIL "<-4730417346358914070@unknownmsgid>") BODYSTRUCTURE (("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 105 (NIL NIL ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "pangalink.net")) NIL NIL "<test1>" NIL) ("TEXT" "PLAIN" NIL NIL NIL "7BIT" 12 0 NIL NIL NIL) 5 NIL NIL NIL) ("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 83 (NIL NIL ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "pangalink.net")) NIL NIL "NIL" NIL) ("TEXT" "PLAIN" NIL NIL NIL "7BIT" 12 0 NIL NIL NIL) 4 NIL NIL NIL) ("TEXT" "HTML" ("CHARSET" "utf-8") NIL NIL "QUOTED-PRINTABLE" 19 0 NIL NIL NIL) "MIXED" ("BOUNDARY" "----mailcomposer-?=_1-1328088797399") NIL NIL))',
parsed = imapHandler.parser(command, {
allowUntagged: true
});
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
});
describe('Types', function () {
let parsed;
beforeEach(function () {
parsed = {
tag: '*',
command: 'CMD'
};
});
describe('No attributes', function () {
it('should compile correctly', function (done) {
let command = '* CMD';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
});
describe('TEXT', function () {
it('should compile correctly', function (done) {
parsed.attributes = [{
type: 'TEXT',
value: 'Tere tere!'
}];
let command = '* CMD Tere tere!';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
});
describe('SECTION', function () {
it('should compile correctly', function (done) {
parsed.attributes = [{
type: 'SECTION',
section: [{
type: 'ATOM',
value: 'ALERT'
}]
}];
let command = '* CMD [ALERT]';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
});
describe('ATOM', function () {
it('should compile correctly', function (done) {
parsed.attributes = [{
type: 'ATOM',
value: 'ALERT'
}, {
type: 'ATOM',
value: '\\ALERT'
}, {
type: 'ATOM',
value: 'NO ALERT'
}];
let command = '* CMD ALERT \\ALERT "NO ALERT"';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
});
describe('SEQUENCE', function () {
it('should compile correctly', function (done) {
parsed.attributes = [{
type: 'SEQUENCE',
value: '*:4,5,6'
}];
let command = '* CMD *:4,5,6';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
});
describe('NIL', function () {
it('should compile correctly', function (done) {
parsed.attributes = [
null,
null
];
let command = '* CMD NIL NIL';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
});
describe('TEXT', function () {
it('should compile correctly', function (done) {
parsed.attributes = [
// keep indentation
{
type: 'String',
value: 'Tere tere!',
sensitive: true
},
'Vana kere'
];
let command = '* CMD "Tere tere!" "Vana kere"';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
it('should keep short strings', function (done) {
parsed.attributes = [
// keep indentation
{
type: 'String',
value: 'Tere tere!'
},
'Vana kere'
];
let command = '* CMD "Tere tere!" "Vana kere"';
resolveStream(imapHandler.compileStream(parsed, true), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
it('should hide strings', function (done) {
parsed.attributes = [
// keep indentation
{
type: 'String',
value: 'Tere tere!',
sensitive: true
},
'Vana kere'
];
let command = '* CMD "(* value hidden *)" "Vana kere"';
resolveStream(imapHandler.compileStream(parsed, true), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
it('should hide long strings', function (done) {
parsed.attributes = [
// keep indentation
{
type: 'String',
value: 'Tere tere! Tere tere! Tere tere! Tere tere! Tere tere!'
},
'Vana kere'
];
let command = '* CMD "(* 54B string *)" "Vana kere"';
resolveStream(imapHandler.compileStream(parsed, true), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
});
describe('No Command', function () {
it('should compile correctly', function (done) {
parsed = {
tag: '*',
attributes: [
1, {
type: 'ATOM',
value: 'EXPUNGE'
}
]
};
let command = '* 1 EXPUNGE';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
});
describe('Literal', function () {
it('shoud return as text', function (done) {
let parsed = {
tag: '*',
command: 'CMD',
attributes: [
// keep indentation
{
type: 'LITERAL',
value: 'Tere tere!'
},
'Vana kere'
]
};
let command = '* CMD {10}\r\nTere tere! "Vana kere"';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
it('should compile correctly without tag and command', function (done) {
let parsed = {
attributes: [{
type: 'LITERAL',
value: 'Tere tere!'
}, {
type: 'LITERAL',
value: 'Vana kere'
}]
};
let command = '{10}\r\nTere tere! {9}\r\nVana kere';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
it('shoud return byte length', function (done) {
let parsed = {
tag: '*',
command: 'CMD',
attributes: [
// keep indentation
{
type: 'LITERAL',
value: 'Tere tere!'
},
'Vana kere'
]
};
let command = '* CMD "(* 10B literal *)" "Vana kere"';
resolveStream(imapHandler.compileStream(parsed, true), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
it('should mix with other values', function (done) {
let s = new PassThrough();
let str = 'abc'.repeat(100);
let parsed = {
attributes: [{
type: 'ATOM',
value: 'BODY'
}, {
type: 'LITERAL',
value: 'Tere tere!'
}, {
type: 'ATOM',
value: 'BODY'
}, {
type: 'LITERAL',
//value: str
value: s,
expectedLength: str.length
}, {
type: 'ATOM',
value: 'UID'
}, {
type: 'ATOM',
value: '12345'
}]
};
setImmediate(function(){
s.end(str);
});
let command = 'BODY {10}\r\nTere tere! BODY {' + str.length + '}\r\n' + str + ' UID 12345';
resolveStream(imapHandler.compileStream(parsed), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
});
it('shoud pipe literal streams', function (done) {
let stream1 = new PassThrough();
let stream2 = new PassThrough();
let stream3 = new PassThrough();
let parsed = {
tag: '*',
command: 'CMD',
attributes: [
// keep indentation
{
type: 'LITERAL',
value: 'Tere tere!'
}, {
type: 'LITERAL',
expectedLength: 5,
value: stream1
},
'Vana kere', {
type: 'LITERAL',
expectedLength: 7,
value: stream2
}, {
type: 'LITERAL',
value: 'Kuidas laheb?'
}, {
type: 'LITERAL',
expectedLength: 5,
value: stream3
}
]
};
let command = '* CMD {10}\r\nTere tere! {5}\r\ntest1 "Vana kere" {7}\r\ntest2 {13}\r\nKuidas laheb? {5}\r\ntest3';
resolveStream(imapHandler.compileStream(parsed, false), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
setTimeout(() => {
stream2.end('test2');
setTimeout(() => {
stream1.end('test1');
setTimeout(() => {
stream3.end('test3');
}, 100);
}, 100);
}, 100);
});
it('shoud pipe limited literal streams', function (done) {
let stream1 = new PassThrough();
let stream2 = new PassThrough();
let stream3 = new PassThrough();
let parsed = {
tag: '*',
command: 'CMD',
attributes: [
// keep indentation
{
type: 'LITERAL',
value: 'Tere tere!'
}, {
type: 'LITERAL',
expectedLength: 5,
value: stream1,
startFrom: 2,
maxLength: 2
},
'Vana kere', {
type: 'LITERAL',
expectedLength: 7,
value: stream2,
startFrom: 2
}, {
type: 'LITERAL',
value: 'Kuidas laheb?'
}, {
type: 'LITERAL',
expectedLength: 7,
value: stream3,
startFrom: 2,
maxLength: 2
}
]
};
let command = '* CMD {10}\r\nTere tere! {2}\r\nst "Vana kere" {5}\r\nst2 {13}\r\nKuidas laheb? {2}\r\nst';
resolveStream(imapHandler.compileStream(parsed, false), (err, compiled) => {
expect(err).to.not.exist;
expect(compiled.toString('binary')).to.equal(command);
done();
});
setTimeout(() => {
stream2.end('test2');
setTimeout(() => {
stream1.end('test1');
setTimeout(() => {
stream3.end('test3');
}, 100);
}, 100);
}, 100);
});
it('shoud pipe errors for literal streams', function (done) {
let stream1 = new PassThrough();
let parsed = {
tag: '*',
command: 'CMD',
attributes: [
// keep indentation
{
type: 'LITERAL',
value: 'Tere tere!'
}, {
type: 'LITERAL',
expectedLength: 5,
value: stream1
}
]
};
resolveStream(imapHandler.compileStream(parsed, false), err => {
expect(err).to.exist;
done();
});
setTimeout(() => {
stream1.emit('error', new Error('Stream error'));
}, 100);
});
});
});
describe('#LengthLimiter', function () {
this.timeout(10000); //eslint-disable-line no-invalid-this
it('should emit exact length', function (done) {
let len = 1024;
let limiter = new imapHandler.compileStream.LengthLimiter(len);
let expected = 'X'.repeat(len);
resolveStream(limiter, (err, value) => {
value = value.toString();
expect(err).to.not.exist;
expect(value).to.equal(expected);
done();
});
let emitted = 0;
let emitter = () => {
let str = 'X'.repeat(128);
emitted += str.length;
limiter.write(new Buffer(str));
if (emitted >= len) {
limiter.end();
} else {
setTimeout(emitter, 100);
}
};
setTimeout(emitter, 100);
});
it('should truncate output', function (done) {
let len = 1024;
let limiter = new imapHandler.compileStream.LengthLimiter(len - 100);
let expected = 'X'.repeat(len - 100);
resolveStream(limiter, (err, value) => {
value = value.toString();
expect(err).to.not.exist;
expect(value).to.equal(expected);
done();
});
let emitted = 0;
let emitter = () => {
let str = 'X'.repeat(128);
emitted += str.length;
limiter.write(new Buffer(str));
if (emitted >= len) {
limiter.end();
} else {
setTimeout(emitter, 100);
}
};
setTimeout(emitter, 100);
});
it('should skip output', function (done) {
let len = 1024;
let limiter = new imapHandler.compileStream.LengthLimiter(len - 100, false, 30);
let expected = 'X'.repeat(len - 100 - 30);
resolveStream(limiter, (err, value) => {
value = value.toString();
expect(err).to.not.exist;
expect(value).to.equal(expected);
done();
});
let emitted = 0;
let emitter = () => {
let str = 'X'.repeat(128);
emitted += str.length;
limiter.write(new Buffer(str));
if (emitted >= len) {
limiter.end();
} else {
setTimeout(emitter, 100);
}
};
setTimeout(emitter, 100);
});
it('should pad output', function (done) {
let len = 1024;
let limiter = new imapHandler.compileStream.LengthLimiter(len + 100);
let expected = 'X'.repeat(len) + ' '.repeat(100);
resolveStream(limiter, (err, value) => {
value = value.toString();
expect(err).to.not.exist;
expect(value).to.equal(expected);
done();
});
let emitted = 0;
let emitter = () => {
let str = 'X'.repeat(128);
emitted += str.length;
limiter.write(new Buffer(str));
if (emitted >= len) {
limiter.end();
} else {
setTimeout(emitter, 100);
}
};
setTimeout(emitter, 100);
});
it('should pipe to several streams', function (done) {
let len = 1024;
let start = 30;
let margin = 200;
let limiter = new imapHandler.compileStream.LengthLimiter(len - margin, false, start);
let stream = new PassThrough();
let input = crypto.randomBytes(len / 2).toString('hex');
let expected1 = input.substr(start, len - margin - start);
let expected2 = input.substr(len - margin);
limiter.on('done', function (remainder) {
stream.unpipe(limiter);
if (remainder) {
stream.unshift(remainder);
}
limiter.end();
});
resolveStream(limiter, (err, value) => {
value = value.toString();
expect(err).to.not.exist;
expect(value).to.equal(expected1);
resolveStream(stream, (err, value) => {
value = value.toString();
expect(err).to.not.exist;
expect(value).to.equal(expected2);
done();
});
});
let emitted = 0;
let emitter = () => {
let str = input.substr(emitted, 128);
emitted += str.length;
stream.write(new Buffer(str));
if (emitted >= len) {
stream.end();
} else {
setImmediate(emitter);
}
};
stream.pipe(limiter);
setImmediate(emitter);
});
});
});
function resolveStream(stream, callback) {
let chunks = [];
let chunklen = 0;
stream.on('readable', () => {
let chunk;
while ((chunk = stream.read()) !== null) {
chunks.push(chunk);
chunklen += chunk.length;
}
});
stream.on('error', err => callback(err));
stream.on('end', () => callback(null, Buffer.concat(chunks, chunklen)));
}

View file

@ -0,0 +1,255 @@
/*eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */
'use strict';
let chai = require('chai');
let imapHandler = require('../lib/handler/imap-handler');
let expect = chai.expect;
chai.config.includeStack = true;
describe('IMAP Command Compiler', function () {
describe('#compile', function () {
it('should compile correctly', function () {
let command = '* FETCH (ENVELOPE ("Mon, 2 Sep 2013 05:30:13 -0700 (PDT)" NIL ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "tr.ee")) NIL NIL NIL "<-4730417346358914070@unknownmsgid>") BODYSTRUCTURE (("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 105 (NIL NIL ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "pangalink.net")) NIL NIL "<test1>" NIL) ("TEXT" "PLAIN" NIL NIL NIL "7BIT" 12 0 NIL NIL NIL) 5 NIL NIL NIL) ("MESSAGE" "RFC822" NIL NIL NIL "7BIT" 83 (NIL NIL ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "kreata.ee")) ((NIL NIL "andris" "pangalink.net")) NIL NIL "NIL" NIL) ("TEXT" "PLAIN" NIL NIL NIL "7BIT" 12 0 NIL NIL NIL) 4 NIL NIL NIL) ("TEXT" "HTML" ("CHARSET" "utf-8") NIL NIL "QUOTED-PRINTABLE" 19 0 NIL NIL NIL) "MIXED" ("BOUNDARY" "----mailcomposer-?=_1-1328088797399") NIL NIL))',
parsed = imapHandler.parser(command, {
allowUntagged: true
}),
compiled = imapHandler.compiler(parsed);
expect(compiled).to.equal(command);
});
});
describe('Types', function () {
let parsed;
beforeEach(function () {
parsed = {
tag: '*',
command: 'CMD'
};
});
describe('No attributes', function () {
it('should compile correctly', function () {
expect(imapHandler.compiler(parsed)).to.equal('* CMD');
});
});
describe('TEXT', function () {
it('should compile correctly', function () {
parsed.attributes = [{
type: 'TEXT',
value: 'Tere tere!'
}];
expect(imapHandler.compiler(parsed)).to.equal('* CMD Tere tere!');
});
});
describe('SECTION', function () {
it('should compile correctly', function () {
parsed.attributes = [{
type: 'SECTION',
section: [{
type: 'ATOM',
value: 'ALERT'
}]
}];
expect(imapHandler.compiler(parsed)).to.equal('* CMD [ALERT]');
});
});
describe('ATOM', function () {
it('should compile correctly', function () {
parsed.attributes = [{
type: 'ATOM',
value: 'ALERT'
}, {
type: 'ATOM',
value: '\\ALERT'
}, {
type: 'ATOM',
value: 'NO ALERT'
}];
expect(imapHandler.compiler(parsed)).to.equal('* CMD ALERT \\ALERT "NO ALERT"');
});
});
describe('SEQUENCE', function () {
it('should compile correctly', function () {
parsed.attributes = [{
type: 'SEQUENCE',
value: '*:4,5,6'
}];
expect(imapHandler.compiler(parsed)).to.equal('* CMD *:4,5,6');
});
});
describe('NIL', function () {
it('should compile correctly', function () {
parsed.attributes = [
null,
null
];
expect(imapHandler.compiler(parsed)).to.equal('* CMD NIL NIL');
});
});
describe('TEXT', function () {
it('should compile correctly', function () {
parsed.attributes = [
// keep indentation
{
type: 'String',
value: 'Tere tere!',
sensitive: true
},
'Vana kere'
];
expect(imapHandler.compiler(parsed)).to.equal('* CMD "Tere tere!" "Vana kere"');
});
it('should keep short strings', function () {
parsed.attributes = [
// keep indentation
{
type: 'String',
value: 'Tere tere!'
},
'Vana kere'
];
expect(imapHandler.compiler(parsed, false, true)).to.equal('* CMD "Tere tere!" "Vana kere"');
});
it('should hide strings', function () {
parsed.attributes = [
// keep indentation
{
type: 'String',
value: 'Tere tere!',
sensitive: true
},
'Vana kere'
];
expect(imapHandler.compiler(parsed, false, true)).to.equal('* CMD "(* value hidden *)" "Vana kere"');
});
it('should hide long strings', function () {
parsed.attributes = [
// keep indentation
{
type: 'String',
value: 'Tere tere! Tere tere! Tere tere! Tere tere! Tere tere!'
},
'Vana kere'
];
expect(imapHandler.compiler(parsed, false, true)).to.equal('* CMD "(* 54B string *)" "Vana kere"');
});
});
describe('No Command', function () {
it('should compile correctly', function () {
parsed = {
tag: '*',
attributes: [
1, {
type: 'ATOM',
value: 'EXPUNGE'
}
]
};
expect(imapHandler.compiler(parsed)).to.equal('* 1 EXPUNGE');
});
});
describe('Literal', function () {
it('shoud return as text', function () {
let parsed = {
tag: '*',
command: 'CMD',
attributes: [
// keep indentation
{
type: 'LITERAL',
value: 'Tere tere!'
},
'Vana kere'
]
};
expect(imapHandler.compiler(parsed)).to.equal('* CMD {10}\r\nTere tere! "Vana kere"');
});
it('should return as an array text 1', function () {
let parsed = {
tag: '*',
command: 'CMD',
attributes: [{
type: 'LITERAL',
value: 'Tere tere!'
}, {
type: 'LITERAL',
value: 'Vana kere'
}]
};
expect(imapHandler.compiler(parsed, true)).to.deep.equal(['* CMD {10}\r\n', 'Tere tere! {9}\r\n', 'Vana kere']);
});
it('should return as an array text 2', function () {
let parsed = {
tag: '*',
command: 'CMD',
attributes: [
// keep indentation
{
type: 'LITERAL',
value: 'Tere tere!'
}, {
type: 'LITERAL',
value: 'Vana kere'
},
'zzz'
]
};
expect(imapHandler.compiler(parsed, true)).to.deep.equal(['* CMD {10}\r\n', 'Tere tere! {9}\r\n', 'Vana kere "zzz"']);
});
it('should compile correctly without tag and command', function () {
let parsed = {
attributes: [{
type: 'LITERAL',
value: 'Tere tere!'
}, {
type: 'LITERAL',
value: 'Vana kere'
}]
};
expect(imapHandler.compiler(parsed, true)).to.deep.equal(['{10}\r\n', 'Tere tere! {9}\r\n', 'Vana kere']);
});
it('shoud return byte length', function () {
let parsed = {
tag: '*',
command: 'CMD',
attributes: [
// keep indentation
{
type: 'LITERAL',
value: 'Tere tere!'
},
'Vana kere'
]
};
expect(imapHandler.compiler(parsed, false, true)).to.equal('* CMD "(* 10B literal *)" "Vana kere"');
});
});
});
});

View file

@ -0,0 +1,232 @@
/* eslint-disable global-require */
/* eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */
'use strict';
let chai = require('chai');
let expect = chai.expect;
let http = require('http');
let fs = require('fs');
let Indexer = require('../lib/indexer/indexer');
let indexer = new Indexer();
chai.config.includeStack = true;
const HTTP_PORT = 9998;
let fixtures = {
simple: {
eml: fs.readFileSync(__dirname + '/fixtures/simple.eml'),
tree: require('./fixtures/simple.json')
},
mimetorture: {
eml: fs.readFileSync(__dirname + '/fixtures/mimetorture.eml'),
tree: require('./fixtures/mimetorture.json')
}
};
describe('#parseMimeTree', function () {
it('should parse mime message', function (done) {
let parsed = indexer.parseMimeTree(fixtures.simple.eml);
expect(parsed).to.deep.equal(fixtures.simple.tree);
parsed = indexer.parseMimeTree(fixtures.mimetorture.eml);
expect(parsed).to.deep.equal(fixtures.mimetorture.tree);
indexer.bodyQuery(parsed, '', (err, data) => {
expect(err).to.not.exist;
expect(data.toString('binary').replace(/\r?\n/g, '\n')).to.equal(fixtures.mimetorture.eml.toString('binary').replace(/\r?\n/g, '\n'));
done();
});
});
});
describe('#rebuild', function () {
let httpServer;
beforeEach(function (done) {
httpServer = http.createServer(function (req, res) {
res.writeHead(200, {
'Content-Type': 'text/plain'
});
if (req.url === '/qp') {
res.end('<p>Krediitkaardiga on tehtud kulutusi, mida oleks saanud v=C3=B5i pidanud =\r\ntegema muul viisil kui kaardiga. Krediitkaardiga tehtud kulude kohta ei ole=\r\n t=C3=A4htaegselt esitatud aruandlust, kuludokumentidel ei kajastu =\r\npiisavaid selgitusi, mist=C3=B5ttu esineb olulisi piiranguid kulude =\r\nsihip=C3=A4rasuse ja otstarbekuse hindamisel ning kulude p=C3=B5hjendatuse =\r\nkontrollimine on raskendatud.</p>');
} else {
res.end('Hello World! '.repeat(20) + 'Bye!');
}
});
httpServer.listen(HTTP_PORT, done);
});
afterEach(function (done) {
httpServer.close(done);
});
it('should rebuild using stream', function (done) {
let message = `Content-Type: multipart/mixed;
boundary="foo"
--foo
Content-Type: text/plain
Content-Transfer-Encoding: base64
X-Attachment-Stream-Size: 264
X-Attachment-Stream-Url: <http://localhost:${HTTP_PORT}/>
--foo--
`;
let parsed = indexer.parseMimeTree(message);
indexer.bodyQuery(parsed, '', (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(492);
done();
});
});
it('should rebuild stream part', function (done) {
let message = `Content-Type: multipart/mixed;
boundary="foo"
--foo
Content-Type: text/plain
Content-Transfer-Encoding: base64
X-Attachment-Stream-Size: 264
X-Attachment-Stream-Url: <http://localhost:${HTTP_PORT}/>
--foo--
`;
let parsed = indexer.parseMimeTree(message);
indexer.bodyQuery(parsed, {
path: '1',
type: ''
}, (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(360);
done();
});
});
it('should rebuild using stream', function (done) {
let message = `Content-Type: text/plain
Content-Transfer-Encoding: base64
X-Attachment-Stream-Size: 264
X-Attachment-Stream-Url: <http://localhost:${HTTP_PORT}/>
`;
let parsed = indexer.parseMimeTree(message);
indexer.bodyQuery(parsed, '', (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(423);
indexer.bodyQuery(parsed, {
path: '1',
type: ''
}, (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(360);
done();
});
});
});
it('should rebuild using stream with truncated content', function (done) {
let message = `Content-Type: text/plain
Content-Transfer-Encoding: base64
X-Attachment-Stream-Size: 150
X-Attachment-Stream-Url: <http://localhost:${HTTP_PORT}/>
`;
let parsed = indexer.parseMimeTree(message);
indexer.bodyQuery(parsed, '', (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(267);
indexer.bodyQuery(parsed, {
path: '1',
type: ''
}, (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(204);
done();
});
});
});
it('should rebuild using stream with padded content', function (done) {
let message = `Content-Type: text/plain
Content-Transfer-Encoding: base64
X-Attachment-Stream-Size: 280
X-Attachment-Stream-Url: <http://localhost:${HTTP_PORT}/>
`;
let parsed = indexer.parseMimeTree(message);
indexer.bodyQuery(parsed, '', (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(447);
indexer.bodyQuery(parsed, {
path: '1',
type: ''
}, (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(384);
done();
});
});
});
it('should return correct attachment size in bodystructure', function () {
let message = `Content-Type: multipart/mixed;
boundary="foo"
--foo
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
Hello world!
--foo
Content-Type: application/octet-stream
Content-Disposition: attachment; filename=normal.bin
Content-Transfer-Encoding: 7bit
12345678901234567890
--foo
Content-Type: application/octet-stream
Content-Disposition: attachment; filename=stream.bin
Content-Transfer-Encoding: base64
X-Attachment-Stream-Size: 264
X-Attachment-Stream-Url: <http://localhost:${HTTP_PORT}/>
--foo--
`;
let parsed = indexer.parseMimeTree(message);
let bodystruct = indexer.getBodyStructure(parsed);
expect(bodystruct[1][6]).to.equal(20);
expect(bodystruct[2][6]).to.equal(360);
});
it('should rebuild using encoded stream', function (done) {
let message = `Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: quoted-printable
X-Attachment-Stream-Size: 407
X-Attachment-Stream-Url: <http://localhost:${HTTP_PORT}/qp>
X-Attachment-Stream-Lines: 6
X-Attachment-Stream-Encoded: Yes
`;
let parsed = indexer.parseMimeTree(message);
indexer.bodyQuery(parsed, '', (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(494);
indexer.bodyQuery(parsed, {
path: '1',
type: ''
}, (err, data) => {
expect(err).to.not.exist;
expect(data.length).to.equal(407);
done();
});
});
});
});

View file

@ -0,0 +1,772 @@
/*eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */
'use strict';
let chai = require('chai');
let imapHandler = require('../lib/handler/imap-handler');
let mimetorture = require('./fixtures/mimetorture');
let expect = chai.expect;
chai.config.includeStack = true;
describe('IMAP Command Parser', function () {
describe('get tag', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD').tag).to.equal('TAG1');
});
it('should fail for unexpected WS', function () {
expect(function () {
imapHandler.parser(' TAG CMD');
}).to.throw(Error);
});
it('should * OK ', function () {
expect(function () {
imapHandler.parser(' TAG CMD');
}).to.throw(Error);
});
it('should + OK ', function () {
expect(imapHandler.parser('+ TAG CMD').tag).to.equal('+');
});
it('should allow untagged', function () {
expect(function () {
imapHandler.parser('* CMD');
}).to.not.throw(Error);
});
it('should fail for empty tag', function () {
expect(function () {
imapHandler.parser('');
}).to.throw(Error);
});
it('should fail for unexpected end', function () {
expect(function () {
imapHandler.parser('TAG1');
}).to.throw(Error);
});
it('should fail for invalid char', function () {
expect(function () {
imapHandler.parser('TAG"1 CMD');
}).to.throw(Error);
});
});
describe('get arguments', function () {
it('should allow trailing whitespace and empty arguments', function () {
expect(function () {
imapHandler.parser('* SEARCH ');
}).to.not.throw(Error);
});
});
describe('get command', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD').command).to.equal('CMD');
});
it('should work for multi word command', function () {
expect(imapHandler.parser('TAG1 UID FETCH').command).to.equal('UID FETCH');
});
it('should fail for unexpected WS', function () {
expect(function () {
imapHandler.parser('TAG1 CMD');
}).to.throw(Error);
});
it('should fail for empty command', function () {
expect(function () {
imapHandler.parser('TAG1 ');
}).to.throw(Error);
});
it('should fail for invalid char', function () {
expect(function () {
imapHandler.parser('TAG1 CM=D');
}).to.throw(Error);
});
});
describe('get attribute', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD FED').attributes).to.deep.equal([{
type: 'ATOM',
value: 'FED'
}]);
});
it('should succeed for single whitespace between values', function () {
expect(imapHandler.parser('TAG1 CMD FED TED').attributes).to.deep.equal([{
type: 'ATOM',
value: 'FED'
}, {
type: 'ATOM',
value: 'TED'
}]);
});
it('should succeed for ATOM', function () {
expect(imapHandler.parser('TAG1 CMD ABCDE').attributes).to.deep.equal([{
type: 'ATOM',
value: 'ABCDE'
}]);
expect(imapHandler.parser('TAG1 CMD ABCDE DEFGH').attributes).to.deep.equal([{
type: 'ATOM',
value: 'ABCDE'
}, {
type: 'ATOM',
value: 'DEFGH'
}]);
expect(imapHandler.parser('TAG1 CMD %').attributes).to.deep.equal([{
type: 'ATOM',
value: '%'
}]);
expect(imapHandler.parser('TAG1 CMD \\*').attributes).to.deep.equal([{
type: 'ATOM',
value: '\\*'
}]);
expect(imapHandler.parser('12.82 STATUS [Gmail].Trash (UIDNEXT UNSEEN HIGHESTMODSEQ)').attributes).to.deep.equal([
// keep indentation
{
type: 'ATOM',
value: '[Gmail].Trash'
},
[{
type: 'ATOM',
value: 'UIDNEXT'
}, {
type: 'ATOM',
value: 'UNSEEN'
}, {
type: 'ATOM',
value: 'HIGHESTMODSEQ'
}]
]);
});
it('should not succeed for ATOM', function () {
expect(function () {
imapHandler.parser('TAG1 CMD \\*a');
}).to.throw(Error);
});
});
describe('get string', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD "ABCDE"').attributes).to.deep.equal([{
type: 'STRING',
value: 'ABCDE'
}]);
expect(imapHandler.parser('TAG1 CMD "ABCDE" "DEFGH"').attributes).to.deep.equal([{
type: 'STRING',
value: 'ABCDE'
}, {
type: 'STRING',
value: 'DEFGH'
}]);
});
it('should not explode on invalid char', function () {
expect(imapHandler.parser('* 1 FETCH (BODY[] "\xc2")').attributes).to.deep.equal([
// keep indentation
{
type: 'ATOM',
value: 'FETCH'
},
[{
type: 'ATOM',
value: 'BODY',
section: []
}, {
type: 'STRING',
value: '\xc2'
}]
]);
});
});
describe('get list', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD (1234)').attributes).to.deep.equal([
[{
type: 'ATOM',
value: '1234'
}]
]);
expect(imapHandler.parser('TAG1 CMD (1234 TERE)').attributes).to.deep.equal([
[{
type: 'ATOM',
value: '1234'
}, {
type: 'ATOM',
value: 'TERE'
}]
]);
expect(imapHandler.parser('TAG1 CMD (1234)(TERE)').attributes).to.deep.equal([
[{
type: 'ATOM',
value: '1234'
}],
[{
type: 'ATOM',
value: 'TERE'
}]
]);
expect(imapHandler.parser('TAG1 CMD ( 1234)').attributes).to.deep.equal([
[{
type: 'ATOM',
value: '1234'
}]
]);
// Trailing whitespace in a BODYSTRUCTURE atom list has been
// observed on yahoo.co.jp's
expect(imapHandler.parser('TAG1 CMD (1234 )').attributes).to.deep.equal([
[{
type: 'ATOM',
value: '1234'
}]
]);
expect(imapHandler.parser('TAG1 CMD (1234) ').attributes).to.deep.equal([
[{
type: 'ATOM',
value: '1234'
}]
]);
});
});
describe('nested list', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD (((TERE)) VANA)').attributes).to.deep.equal([
[
[
[{
type: 'ATOM',
value: 'TERE'
}]
], {
type: 'ATOM',
value: 'VANA'
}
]
]);
expect(imapHandler.parser('TAG1 CMD (( (TERE)) VANA)').attributes).to.deep.equal([
[
[
[{
type: 'ATOM',
value: 'TERE'
}]
], {
type: 'ATOM',
value: 'VANA'
}
]
]);
expect(imapHandler.parser('TAG1 CMD (((TERE) ) VANA)').attributes).to.deep.equal([
[
[
[{
type: 'ATOM',
value: 'TERE'
}]
], {
type: 'ATOM',
value: 'VANA'
}
]
]);
});
});
describe('get literal', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD {4}\r\nabcd').attributes).to.deep.equal([{
type: 'LITERAL',
value: 'abcd'
}]);
expect(imapHandler.parser('TAG1 CMD {4}\r\nabcd {4}\r\nkere').attributes).to.deep.equal([{
type: 'LITERAL',
value: 'abcd'
}, {
type: 'LITERAL',
value: 'kere'
}]);
expect(imapHandler.parser('TAG1 CMD ({4}\r\nabcd {4}\r\nkere)').attributes).to.deep.equal([
[{
type: 'LITERAL',
value: 'abcd'
}, {
type: 'LITERAL',
value: 'kere'
}]
]);
});
it('should fail', function () {
expect(function () {
imapHandler.parser('TAG1 CMD {4}\r\nabcd{4} \r\nkere');
}).to.throw(Error);
});
it('should allow zero length literal in the end of a list', function () {
expect(imapHandler.parser('TAG1 CMD ({0}\r\n)').attributes).to.deep.equal([
[{
type: 'LITERAL',
value: ''
}]
]);
});
});
describe('ATOM Section', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD BODY[]').attributes).to.deep.equal([{
type: 'ATOM',
value: 'BODY',
section: []
}]);
expect(imapHandler.parser('TAG1 CMD BODY[(KERE)]').attributes).to.deep.equal([{
type: 'ATOM',
value: 'BODY',
section: [
[{
type: 'ATOM',
value: 'KERE'
}]
]
}]);
});
it('will not fail due to trailing whitespace', function () {
// We intentionally have trailing whitespace in the section here
// because we altered the parser to handle this when we made it
// legal for lists and it makes sense to accordingly test it.
// However, we have no recorded incidences of this happening in
// reality (unlike for lists).
expect(imapHandler.parser('TAG1 CMD BODY[HEADER.FIELDS (Subject From) ]').attributes).to.deep.equal([{
type: 'ATOM',
value: 'BODY',
section: [
// keep indentation
{
type: 'ATOM',
value: 'HEADER.FIELDS'
},
[{
type: 'ATOM',
value: 'Subject'
}, {
type: 'ATOM',
value: 'From'
}]
]
}]);
});
it('should fail where default BODY and BODY.PEEK are allowed to have sections', function () {});
expect(function () {
imapHandler.parser('TAG1 CMD KODY[]');
}).to.throw(Error);
});
describe('Human readable', function () {
it('should succeed', function () {
expect(imapHandler.parser('* OK [CAPABILITY IDLE] Hello world!')).to.deep.equal({
command: 'OK',
tag: '*',
attributes: [{
section: [{
type: 'ATOM',
value: 'CAPABILITY'
}, {
type: 'ATOM',
value: 'IDLE'
}],
type: 'ATOM',
value: ''
}, {
type: 'TEXT',
value: 'Hello world!'
}]
});
expect(imapHandler.parser('* OK Hello world!')).to.deep.equal({
command: 'OK',
tag: '*',
attributes: [{
type: 'TEXT',
value: 'Hello world!'
}]
});
expect(imapHandler.parser('* OK')).to.deep.equal({
command: 'OK',
tag: '*'
});
// USEATTR is from RFC6154; we are testing that just an ATOM
// on its own will parse successfully here. (All of the
// RFC5530 codes are also single atoms.)
expect(imapHandler.parser('TAG1 OK [USEATTR] \\All not supported')).to.deep.equal({
tag: 'TAG1',
command: 'OK',
attributes: [{
type: 'ATOM',
value: '',
section: [{
type: 'ATOM',
value: 'USEATTR'
}]
}, {
type: 'TEXT',
value: '\\All not supported'
}]
});
// RFC5267 defines the NOUPDATE error. Including for quote /
// string coverage.
expect(imapHandler.parser('* NO [NOUPDATE "B02"] Too many contexts')).to.deep.equal({
tag: '*',
command: 'NO',
attributes: [{
type: 'ATOM',
value: '',
section: [{
type: 'ATOM',
value: 'NOUPDATE'
}, {
type: 'STRING',
value: 'B02'
}]
}, {
type: 'TEXT',
value: 'Too many contexts'
}]
});
// RFC5464 defines the METADATA response code; adding this to
// ensure the transition for when '2199' hits ']' is handled
// safely.
expect(imapHandler.parser('TAG1 OK [METADATA LONGENTRIES 2199] GETMETADATA complete')).to.deep.equal({
tag: 'TAG1',
command: 'OK',
attributes: [{
type: 'ATOM',
value: '',
section: [{
type: 'ATOM',
value: 'METADATA'
}, {
type: 'ATOM',
value: 'LONGENTRIES'
}, {
type: 'ATOM',
value: '2199'
}]
}, {
type: 'TEXT',
value: 'GETMETADATA complete'
}]
});
// RFC4467 defines URLMECH. Included because of the example
// third atom involves base64-encoding which is somewhat unusual
expect(imapHandler.parser('TAG1 OK [URLMECH INTERNAL XSAMPLE=P34OKhO7VEkCbsiYY8rGEg==] done')).to.deep.equal({
tag: 'TAG1',
command: 'OK',
attributes: [{
type: 'ATOM',
value: '',
section: [{
type: 'ATOM',
value: 'URLMECH'
}, {
type: 'ATOM',
value: 'INTERNAL'
}, {
type: 'ATOM',
value: 'XSAMPLE=P34OKhO7VEkCbsiYY8rGEg=='
}]
}, {
type: 'TEXT',
value: 'done'
}]
});
// RFC2221 defines REFERRAL where the argument is an imapurl
// (defined by RFC2192 which is obsoleted by RFC5092) which
// is significantly more complicated than the rest of the IMAP
// grammar and which was based on the RFC2060 grammar where
// resp_text_code included:
// atom [SPACE 1*<any TEXT_CHAR except ']'>]
// So this is just a test case of our explicit special-casing
// of REFERRAL.
expect(imapHandler.parser('TAG1 NO [REFERRAL IMAP://user;AUTH=*@SERVER2/] Remote Server')).to.deep.equal({
tag: 'TAG1',
command: 'NO',
attributes: [{
type: 'ATOM',
value: '',
section: [{
type: 'ATOM',
value: 'REFERRAL'
}, {
type: 'ATOM',
value: 'IMAP://user;AUTH=*@SERVER2/'
}]
}, {
type: 'TEXT',
value: 'Remote Server'
}]
});
// PERMANENTFLAGS is from RFC3501. Its syntax is also very
// similar to BADCHARSET, except BADCHARSET has astrings
// inside the list.
expect(imapHandler.parser('* OK [PERMANENTFLAGS (de:hacking $label kt-evalution [css3-page] \\*)] Flags permitted.')).to.deep.equal({
tag: '*',
command: 'OK',
attributes: [{
type: 'ATOM',
value: '',
section: [
// keep indentation
{
type: 'ATOM',
value: 'PERMANENTFLAGS'
},
[{
type: 'ATOM',
value: 'de:hacking'
}, {
type: 'ATOM',
value: '$label'
}, {
type: 'ATOM',
value: 'kt-evalution'
}, {
type: 'ATOM',
value: '[css3-page]'
}, {
type: 'ATOM',
value: '\\*'
}]
]
}, {
type: 'TEXT',
value: 'Flags permitted.'
}]
});
// COPYUID is from RFC4315 and included the previously failing
// parsing situation of a sequence terminated by ']' rather than
// whitespace.
expect(imapHandler.parser('TAG1 OK [COPYUID 4 1417051618:1417051620 1421730687:1421730689] COPY completed')).to.deep.equal({
tag: 'TAG1',
command: 'OK',
attributes: [{
type: 'ATOM',
value: '',
section: [{
type: 'ATOM',
value: 'COPYUID'
}, {
type: 'ATOM',
value: '4'
}, {
type: 'SEQUENCE',
value: '1417051618:1417051620'
}, {
type: 'SEQUENCE',
value: '1421730687:1421730689'
}]
}, {
type: 'TEXT',
value: 'COPY completed'
}]
});
// MODIFIED is from RFC4551 and is basically the same situation
// as the COPYUID case, but in this case our example sequences
// have commas in them. (Note that if there was no comma, the
// '7,9' payload would end up an ATOM.)
expect(imapHandler.parser('TAG1 OK [MODIFIED 7,9] Conditional STORE failed')).to.deep.equal({
tag: 'TAG1',
command: 'OK',
attributes: [{
type: 'ATOM',
value: '',
section: [{
type: 'ATOM',
value: 'MODIFIED'
}, {
type: 'SEQUENCE',
value: '7,9'
}]
}, {
type: 'TEXT',
value: 'Conditional STORE failed'
}]
});
});
});
describe('ATOM Partial', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD BODY[]<0>').attributes).to.deep.equal([{
type: 'ATOM',
value: 'BODY',
section: [],
partial: [0]
}]);
expect(imapHandler.parser('TAG1 CMD BODY[]<12.45>').attributes).to.deep.equal([{
type: 'ATOM',
value: 'BODY',
section: [],
partial: [12, 45]
}]);
expect(imapHandler.parser('TAG1 CMD BODY[HEADER.FIELDS (Subject From)]<12.45>').attributes).to.deep.equal([{
type: 'ATOM',
value: 'BODY',
section: [
// keep indentation
{
type: 'ATOM',
value: 'HEADER.FIELDS'
},
[{
type: 'ATOM',
value: 'Subject'
}, {
type: 'ATOM',
value: 'From'
}]
],
partial: [12, 45]
}]);
});
it('should fail', function () {
expect(function () {
imapHandler.parser('TAG1 CMD KODY<0.123>');
}).to.throw(Error);
expect(function () {
imapHandler.parser('TAG1 CMD BODY[]<01>');
}).to.throw(Error);
expect(function () {
imapHandler.parser('TAG1 CMD BODY[]<0.01>');
}).to.throw(Error);
expect(function () {
imapHandler.parser('TAG1 CMD BODY[]<0.1.>');
}).to.throw(Error);
});
});
describe('SEQUENCE', function () {
it('should succeed', function () {
expect(imapHandler.parser('TAG1 CMD *:4,5:7 TEST').attributes).to.deep.equal([{
type: 'SEQUENCE',
value: '*:4,5:7'
}, {
type: 'ATOM',
value: 'TEST'
}]);
expect(imapHandler.parser('TAG1 CMD 1:* TEST').attributes).to.deep.equal([{
type: 'SEQUENCE',
value: '1:*'
}, {
type: 'ATOM',
value: 'TEST'
}]);
expect(imapHandler.parser('TAG1 CMD *:4 TEST').attributes).to.deep.equal([{
type: 'SEQUENCE',
value: '*:4'
}, {
type: 'ATOM',
value: 'TEST'
}]);
});
it('should fail', function () {
expect(function () {
imapHandler.parser('TAG1 CMD *:4,5:');
}).to.throw(Error);
expect(function () {
imapHandler.parser('TAG1 CMD *:4,5:TEST TEST');
}).to.throw(Error);
expect(function () {
imapHandler.parser('TAG1 CMD *:4,5: TEST');
}).to.throw(Error);
expect(function () {
imapHandler.parser('TAG1 CMD *4,5 TEST');
}).to.throw(Error);
expect(function () {
imapHandler.parser('TAG1 CMD *,5 TEST');
}).to.throw(Error);
expect(function () {
imapHandler.parser('TAG1 CMD 5,* TEST');
}).to.throw(Error);
expect(function () {
imapHandler.parser('TAG1 CMD 5, TEST');
}).to.throw(Error);
});
});
describe('Escaped quotes', function () {
it('should succeed', function () {
expect(imapHandler.parser('* 331 FETCH (ENVELOPE ("=?ISO-8859-1?Q?\\"G=FCnter__Hammerl\\"?="))').attributes).to.deep.equal([
// keep indentation
{
type: 'ATOM',
value: 'FETCH'
},
[
// keep indentation
{
type: 'ATOM',
value: 'ENVELOPE'
},
[{
type: 'STRING',
value: '=?ISO-8859-1?Q?"G=FCnter__Hammerl"?='
}]
]
]);
});
});
describe('MimeTorture', function () {
it('should parse mimetorture input', function () {
let parsed;
expect(function () {
parsed = imapHandler.parser(mimetorture.input);
}).to.not.throw(Error);
expect(parsed).to.deep.equal(mimetorture.output);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,883 @@
/*eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */
'use strict';
let parseQueryTerms = require('../lib/commands/search').parseQueryTerms;
let matchSearchQuery = require('../lib/search').matchSearchQuery;
let chai = require('chai');
let expect = chai.expect;
chai.config.includeStack = true;
describe('#parseQueryTerms', function () {
let uidList = [39, 40, 44, 52, 53, 54, 59, 72];
describe('<sequence set>', function () {
it('should detect sequence as first argument', function () {
expect(parseQueryTerms('1,2,4:6'.split(' '), uidList).query).to.deep.equal([{
key: 'uid',
value: [39, 40, 52, 53, 54]
}]);
});
it('should detect sequence as subargument', function () {
expect(parseQueryTerms('NOT 1,2,4:6'.split(' '), uidList).query).to.deep.equal([{
key: 'not',
value: {
key: 'uid',
value: [39, 40, 52, 53, 54]
}
}]);
});
});
it('should handle ALL', function () {
expect(parseQueryTerms('ALL'.split(' '), uidList).query).to.deep.equal([{
key: 'all',
value: true
}]);
});
it('should handle ANSWERED', function () {
expect(parseQueryTerms('ANSWERED'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Answered',
exists: true
}]);
});
it('should handle BCC', function () {
expect(parseQueryTerms('BCC query'.split(' '), uidList).query).to.deep.equal([{
key: 'header',
header: 'bcc',
value: 'query'
}]);
});
it('should handle BEFORE', function () {
expect(parseQueryTerms('BEFORE 1-Feb-1994'.split(' '), uidList).query).to.deep.equal([{
key: 'internaldate',
operator: '<',
value: '1-Feb-1994'
}]);
});
it('should handle BODY', function () {
expect(parseQueryTerms('BODY query'.split(' '), uidList).query).to.deep.equal([{
key: 'body',
value: 'query'
}]);
});
it('should handle CC', function () {
expect(parseQueryTerms('CC query'.split(' '), uidList).query).to.deep.equal([{
key: 'header',
header: 'cc',
value: 'query'
}]);
});
it('should handle DELETED', function () {
expect(parseQueryTerms('DELETED'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Deleted',
exists: true
}]);
});
it('should handle DRAFT', function () {
expect(parseQueryTerms('DRAFT'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Draft',
exists: true
}]);
});
it('should handle FLAGGED', function () {
expect(parseQueryTerms('FLAGGED'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Flagged',
exists: true
}]);
});
it('should handle FROM', function () {
expect(parseQueryTerms('FROM query'.split(' '), uidList).query).to.deep.equal([{
key: 'header',
header: 'from',
value: 'query'
}]);
});
it('should handle HEADER', function () {
expect(parseQueryTerms('HEADER X-FOO query'.split(' '), uidList).query).to.deep.equal([{
key: 'header',
header: 'x-foo',
value: 'query'
}]);
expect(parseQueryTerms(['HEADER', 'X-FOO', null], uidList).query).to.deep.equal([{
key: 'header',
header: 'x-foo',
value: ''
}]);
});
it('should handle KEYWORD', function () {
expect(parseQueryTerms('KEYWORD $MyFlag'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '$MyFlag',
exists: true
}]);
});
it('should handle LARGER', function () {
expect(parseQueryTerms('LARGER 123'.split(' '), uidList).query).to.deep.equal([{
key: 'size',
value: 123,
operator: '>'
}]);
});
describe('MODSEQ', function () {
it('should handle only required param', function () {
expect(parseQueryTerms('MODSEQ 123'.split(' '), uidList).query).to.deep.equal([{
key: 'modseq',
value: 123
}]);
});
it('should handle optional params', function () {
expect(parseQueryTerms('MODSEQ "/flags/\\\\draft" all 123'.split(' '), uidList).query).to.deep.equal([{
key: 'modseq',
value: 123
}]);
});
});
it('should handle NEW', function () {
expect(parseQueryTerms('NEW'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Recent',
exists: true
}, {
key: 'flag',
value: '\\Seen',
exists: false
}]);
});
it('should handle NOT', function () {
expect(parseQueryTerms('NOT ALL'.split(' '), uidList).query).to.deep.equal([{
key: 'not',
value: {
key: 'all',
value: true
}
}]);
expect(parseQueryTerms('NOT NOT ALL'.split(' '), uidList).query).to.deep.equal([{
key: 'not',
value: {
key: 'not',
value: {
key: 'all',
value: true
}
}
}]);
});
it('should handle OLD', function () {
expect(parseQueryTerms('OLD'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Recent',
exists: false
}]);
});
it('should handle ON', function () {
expect(parseQueryTerms('ON 1-Feb-1994'.split(' '), uidList).query).to.deep.equal([{
key: 'internaldate',
operator: '=',
value: '1-Feb-1994'
}]);
});
it('should handle OR', function () {
expect(parseQueryTerms('OR ALL NOT ALL'.split(' '), uidList).query).to.deep.equal([{
key: 'or',
value: [{
key: 'all',
value: true
}, {
key: 'not',
value: {
key: 'all',
value: true
}
}]
}]);
});
it('should handle RECENT', function () {
expect(parseQueryTerms('RECENT'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Recent',
exists: true
}]);
});
it('should handle SEEN', function () {
expect(parseQueryTerms('SEEN'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Seen',
exists: true
}]);
});
it('should handle SENTBEFORE', function () {
expect(parseQueryTerms('SENTBEFORE 1-Feb-1994'.split(' '), uidList).query).to.deep.equal([{
key: 'date',
operator: '<',
value: '1-Feb-1994'
}]);
});
it('should handle SENTON', function () {
expect(parseQueryTerms('SENTON 1-Feb-1994'.split(' '), uidList).query).to.deep.equal([{
key: 'date',
operator: '=',
value: '1-Feb-1994'
}]);
});
it('should handle SENTSINCE', function () {
expect(parseQueryTerms('SENTSINCE 1-Feb-1994'.split(' '), uidList).query).to.deep.equal([{
key: 'date',
operator: '>=',
value: '1-Feb-1994'
}]);
});
it('should handle SINCE', function () {
expect(parseQueryTerms('SINCE 1-Feb-1994'.split(' '), uidList).query).to.deep.equal([{
key: 'internaldate',
operator: '>=',
value: '1-Feb-1994'
}]);
});
it('should handle SMALLER', function () {
expect(parseQueryTerms('SMALLER 123'.split(' '), uidList).query).to.deep.equal([{
key: 'size',
value: 123,
operator: '<'
}]);
});
it('should handle SUBJECT', function () {
expect(parseQueryTerms('SUBJECT query'.split(' '), uidList).query).to.deep.equal([{
key: 'header',
header: 'subject',
value: 'query'
}]);
});
it('should handle TEXT', function () {
expect(parseQueryTerms('TEXT query'.split(' '), uidList).query).to.deep.equal([{
key: 'text',
value: 'query'
}]);
});
it('should handle TO', function () {
expect(parseQueryTerms('TO query'.split(' '), uidList).query).to.deep.equal([{
key: 'header',
header: 'to',
value: 'query'
}]);
});
it('should handle UID', function () {
expect(parseQueryTerms('UID 44,54:*'.split(' '), uidList).query).to.deep.equal([{
key: 'uid',
value: [44, 54, 59, 72]
}]);
});
it('should handle UNANSWERED', function () {
expect(parseQueryTerms('UNANSWERED'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Answered',
exists: false
}]);
});
it('should handle UNDELETED', function () {
expect(parseQueryTerms('UNDELETED'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Deleted',
exists: false
}]);
});
it('should handle UNDRAFT', function () {
expect(parseQueryTerms('UNDRAFT'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Draft',
exists: false
}]);
});
it('should handle UNFLAGGED', function () {
expect(parseQueryTerms('UNFLAGGED'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Flagged',
exists: false
}]);
});
it('should handle UNKEYWORD', function () {
expect(parseQueryTerms('UNKEYWORD $MyFlag'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '$MyFlag',
exists: false
}]);
});
it('should handle UNSEEN', function () {
expect(parseQueryTerms('UNSEEN'.split(' '), uidList).query).to.deep.equal([{
key: 'flag',
value: '\\Seen',
exists: false
}]);
});
it('should handle complex query', function () {
// this is a query by iOS Mail app
// UID SEARCH (OR FROM "Time" (OR SUBJECT "Time" (OR TO "Time" (OR CC "Time" BODY "Time")))) NOT DELETED
expect(parseQueryTerms('OR FROM Time OR SUBJECT Time OR TO Time OR CC Time BODY Time NOT DELETED'.split(' '), uidList).query).to.deep.equal([{
key: 'or',
value: [{
key: 'header',
header: 'from',
value: 'Time'
}, {
key: 'or',
value: [{
key: 'header',
header: 'subject',
value: 'Time'
}, {
key: 'or',
value: [{
key: 'header',
header: 'to',
value: 'Time'
}, {
key: 'or',
value: [{
key: 'header',
header: 'cc',
value: 'Time'
}, {
key: 'body',
value: 'Time'
}]
}]
}]
}]
}, {
key: 'not',
value: {
key: 'flag',
value: '\\Deleted',
exists: true
}
}]);
});
});
describe('Search term match tests', function () {
describe('AND', function () {
it('should find all matches', function () {
expect(matchSearchQuery({}, [{
key: 'all',
value: true
}, {
key: 'all',
value: true
}, {
key: 'all',
value: true
}, {
key: 'all',
value: true
}])).to.be.true;
});
it('should fail on single error', function () {
expect(matchSearchQuery({}, [{
key: 'all',
value: true
}, {
key: 'all',
value: true
}, {
key: 'all',
value: true
}, {
key: 'flag',
value: 'zzzzzzz',
exists: true
}])).to.be.false;
});
});
describe('OR', function () {
it('should succeed with at least one match', function () {
expect(matchSearchQuery({}, {
key: 'or',
value: [{
key: 'flag',
value: 'zzzzzzz',
exists: true
}, {
key: 'all',
value: true
}, {
key: 'flag',
value: 'zzzzzzz',
exists: true
}]
})).to.be.true;
});
it('should fail with no matches', function () {
expect(matchSearchQuery({}, {
key: 'or',
value: [{
key: 'flag',
value: 'zzzzzzz',
exists: true
}, {
key: 'flag',
value: 'zzzzzzz',
exists: true
}]
})).to.be.false;
});
});
describe('NOT', function () {
it('should succeed with false value', function () {
expect(matchSearchQuery({}, {
key: 'not',
value: {
key: 'flag',
value: 'zzzzzzz',
exists: true
}
})).to.be.true;
});
it('should fail with thruthy value', function () {
expect(matchSearchQuery({}, {
key: 'not',
value: {
key: 'all',
value: true
}
})).to.be.false;
});
});
describe('ALL', function () {
it('should match ALL', function () {
expect(matchSearchQuery({}, {
key: 'all',
value: true
})).to.be.true;
});
});
describe('FLAG', function () {
it('should match existing flag', function () {
expect(matchSearchQuery({
flags: ['abc', 'def', 'ghi']
}, {
key: 'flag',
value: 'def',
exists: true
})).to.be.true;
});
it('should match non-existing flag', function () {
expect(matchSearchQuery({
flags: ['abc', 'def', 'ghi']
}, {
key: 'flag',
value: 'zzzzz',
exists: false
})).to.be.true;
});
it('should fail non-existing flag', function () {
expect(matchSearchQuery({
flags: ['abc', 'def', 'ghi']
}, {
key: 'flag',
value: 'zzzzz',
exists: true
})).to.be.false;
});
it('should fail existing flag', function () {
expect(matchSearchQuery({
flags: ['abc', 'def', 'ghi']
}, {
key: 'flag',
value: 'abc',
exists: false
})).to.be.false;
});
});
describe('INTERNALDATE', function () {
it('should match <', function () {
expect(matchSearchQuery({
internaldate: new Date('1999-01-01')
}, {
key: 'internaldate',
value: '2001-01-01',
operator: '<'
})).to.be.true;
});
it('should not match <', function () {
expect(matchSearchQuery({
internaldate: new Date('1999-01-01')
}, {
key: 'internaldate',
value: '1998-01-01',
operator: '<'
})).to.be.false;
});
it('should match =', function () {
expect(matchSearchQuery({
internaldate: new Date('1999-01-01')
}, {
key: 'internaldate',
value: '1999-01-01',
operator: '='
})).to.be.true;
});
it('should not match <', function () {
expect(matchSearchQuery({
internaldate: new Date('1999-01-01')
}, {
key: 'internaldate',
value: '1999-01-02',
operator: '='
})).to.be.false;
});
it('should match >=', function () {
expect(matchSearchQuery({
internaldate: new Date('1999-01-01')
}, {
key: 'internaldate',
value: '1999-01-01',
operator: '>='
})).to.be.true;
expect(matchSearchQuery({
internaldate: new Date('1999-01-02')
}, {
key: 'internaldate',
value: '1999-01-01',
operator: '>='
})).to.be.true;
});
it('should not match >=', function () {
expect(matchSearchQuery({
internaldate: new Date('1999-01-01')
}, {
key: 'internaldate',
value: '1999-01-02',
operator: '>='
})).to.be.false;
});
});
describe('DATE', function () {
let raw = 'Subject: test\r\nDate: 1999-01-01\r\n\r\nHello world!';
it('should match <', function () {
expect(matchSearchQuery({
raw
}, {
key: 'date',
value: '2001-01-01',
operator: '<'
})).to.be.true;
});
it('should not match <', function () {
expect(matchSearchQuery({
raw
}, {
key: 'date',
value: '1998-01-01',
operator: '<'
})).to.be.false;
});
it('should match =', function () {
expect(matchSearchQuery({
raw
}, {
key: 'date',
value: '1999-01-01',
operator: '='
})).to.be.true;
});
it('should not match <', function () {
expect(matchSearchQuery({
raw
}, {
key: 'date',
value: '1999-01-02',
operator: '='
})).to.be.false;
});
it('should match >=', function () {
expect(matchSearchQuery({
raw
}, {
key: 'date',
value: '1999-01-01',
operator: '>='
})).to.be.true;
expect(matchSearchQuery({
raw
}, {
key: 'date',
value: '1998-01-01',
operator: '>='
})).to.be.true;
});
it('should not match >=', function () {
expect(matchSearchQuery({
raw
}, {
key: 'date',
value: '1999-01-02',
operator: '>='
})).to.be.false;
});
});
describe('BODY', function () {
let raw = 'Subject: test\r\n\r\nHello world!';
it('should match a string', function () {
expect(matchSearchQuery({
raw
}, {
key: 'body',
value: 'hello'
})).to.be.true;
});
it('should not match a string', function () {
expect(matchSearchQuery({
raw
}, {
key: 'body',
value: 'test'
})).to.be.false;
});
});
describe('TEXT', function () {
let raw = 'Subject: test\r\n\r\nHello world!';
it('should match a string', function () {
expect(matchSearchQuery({
raw
}, {
key: 'text',
value: 'hello'
})).to.be.true;
expect(matchSearchQuery({
raw
}, {
key: 'text',
value: 'test'
})).to.be.true;
});
it('should not match a string', function () {
expect(matchSearchQuery({
raw
}, {
key: 'text',
value: 'zzzzz'
})).to.be.false;
});
});
describe('UID', function () {
it('should match message uid', function () {
expect(matchSearchQuery({
uid: 123
}, {
key: 'uid',
value: [11, 123, 134]
})).to.be.true;
});
it('should not match message uid', function () {
expect(matchSearchQuery({
uid: 124
}, {
key: 'uid',
value: [11, 123, 134]
})).to.be.false;
});
});
describe('SIZE', function () {
it('should match <', function () {
expect(matchSearchQuery({
raw: new Buffer(10)
}, {
key: 'size',
value: 11,
operator: '<'
})).to.be.true;
});
it('should not match <', function () {
expect(matchSearchQuery({
raw: new Buffer(10)
}, {
key: 'size',
value: 9,
operator: '<'
})).to.be.false;
});
it('should match =', function () {
expect(matchSearchQuery({
raw: new Buffer(10)
}, {
key: 'size',
value: 10,
operator: '='
})).to.be.true;
});
it('should not match =', function () {
expect(matchSearchQuery({
raw: new Buffer(10)
}, {
key: 'size',
value: 11,
operator: '='
})).to.be.false;
});
it('should match >', function () {
expect(matchSearchQuery({
raw: new Buffer(10)
}, {
key: 'size',
value: 9,
operator: '>'
})).to.be.true;
});
it('should not match <', function () {
expect(matchSearchQuery({
raw: new Buffer(10)
}, {
key: 'size',
value: 11,
operator: '>'
})).to.be.false;
});
});
describe('header', function () {
let raw = 'Subject: test\r\n\r\nHello world!';
it('should match header value', function () {
expect(matchSearchQuery({
raw
}, {
key: 'header',
value: 'test',
header: 'subject'
})).to.be.true;
});
it('should match empty header value', function () {
expect(matchSearchQuery({
raw
}, {
key: 'header',
value: '',
header: 'subject'
})).to.be.true;
});
it('should not match header value', function () {
expect(matchSearchQuery({
raw
}, {
key: 'header',
value: 'tests',
header: 'subject'
})).to.be.false;
});
});
describe('MODSEQ', function () {
it('should match equal modseq', function () {
expect(matchSearchQuery({
modseq: 500
}, {
key: 'modseq',
value: [500]
})).to.be.true;
});
it('should match greater modseq', function () {
expect(matchSearchQuery({
modseq: 1000
}, {
key: 'modseq',
value: [500]
})).to.be.true;
});
it('should not match lesser modseq', function () {
expect(matchSearchQuery({
modseq: 500
}, {
key: 'modseq',
value: [100]
})).to.be.true;
});
});
});

View file

@ -0,0 +1,142 @@
/*eslint no-console: 0 */
'use strict';
let net = require('net');
let tls = require('tls');
module.exports = runClientMockup;
function runClientMockup(options, callback) {
options = options || {};
let host = options.host || 'localhost';
let port = options.port || 25;
let commands = [].concat(options.commands || []);
let debug = options.debug;
let ignore_data = false;
let responses = [];
let command = '';
let callbackSent = false;
let delay;
let socket = (options.secure ? tls : net).connect({
rejectUnauthorized: false,
port,
host
}, () => {
socket.on('close', () => {
if (callbackSent) {
return;
}
callbackSent = true;
if (typeof callback === 'function') {
return callback(Buffer.concat(responses));
}
});
let onData = function (chunk) {
if (ignore_data) {
return;
}
responses.push(chunk);
if (debug) {
console.log('S: ' + chunk.toString('binary').trim());
}
if (!commands.length) {
return;
}
if (typeof command === 'string' && command.match(/^[a-z0-9]+ STARTTLS$/i)) {
// wait until server sends response to the STARTTLS command
if (!/STARTTLS completed/.test(Buffer.concat(responses).toString())) {
return;
}
ignore_data = true;
if (debug) {
console.log('Initiated TLS connection');
}
socket.removeAllListeners('data');
let secureSocket = tls.connect({
rejectUnauthorized: false,
socket,
host
}, () => {
ignore_data = false;
socket = secureSocket;
if (debug) {
console.log('TLS connection secured');
}
secureSocket.on('data', onData);
secureSocket.on('close', () => {
if (callbackSent) {
return;
}
callbackSent = true;
if (typeof callback === 'function') {
return callback(Buffer.concat(responses));
}
});
command = commands.shift();
if (debug) {
console.log('(Secure) C: ' + command);
}
secureSocket.write(command + '\r\n');
});
secureSocket.on('error', err => {
console.log('SECURE ERR');
console.log(err.stack);
});
} else {
if (!/\r?\n$/.test(chunk.toString('binary'))) {
return;
}
// only go forward with the next command if the last data ends with a newline
// and there is no activity in the socket for 10ms
clearTimeout(delay);
delay = setTimeout(() => {
command = commands.shift();
if (Array.isArray(command)) {
let i = 0;
let send = function () {
if (i >= command.length) {
return;
}
let part = command[i++];
socket.write(new Buffer(part + (i >= command.length ? '\r\n' : ''), 'binary'));
if (debug) {
console.log('C: ' + part);
}
setTimeout(send, 10);
};
send();
} else {
socket.write(new Buffer(command + '\r\n', 'binary'));
if (debug) {
console.log('C: ' + command);
}
}
}, 10);
}
};
socket.on('data', onData);
});
}

View file

@ -0,0 +1,583 @@
'use strict';
let IMAPServerModule = require('../index.js');
let IMAPServer = IMAPServerModule.IMAPServer;
let MemoryNotifier = IMAPServerModule.MemoryNotifier;
let fs = require('fs');
let imapHandler = require('../lib/handler/imap-handler');
module.exports = function (options) {
// This example uses global folders and subscriptions
let folders = new Map();
let subscriptions = new WeakSet();
[{
path: 'INBOX',
uidValidity: 123,
uidNext: 70,
modifyIndex: 5000,
messages: [{
uid: 45,
flags: [],
modseq: 100,
internaldate: new Date('14-Sep-2013 21:22:28 -0300'),
raw: new Buffer('from: sender@example.com\r\nto: to@example.com\r\ncc: cc@example.com\r\nsubject: test\r\n\r\nzzzz')
}, {
uid: 49,
flags: ['\\Seen'],
internaldate: new Date(),
modseq: 5000,
raw: fs.readFileSync(__dirname + '/fixtures/ryan_finnie_mime_torture.eml')
}, {
uid: 50,
flags: ['\\Seen'],
modseq: 45,
internaldate: new Date(),
raw: 'MIME-Version: 1.0\r\n' +
'From: andris@kreata.ee\r\n' +
'To: andris@tr.ee\r\n' +
'Content-Type: multipart/mixed;\r\n' +
' boundary=\'----mailcomposer-?=_1-1328088797399\'\r\n' +
'Message-Id: <testmessage-for-bug>;\r\n' +
'\r\n' +
'------mailcomposer-?=_1-1328088797399\r\n' +
'Content-Type: message/rfc822\r\n' +
'Content-Transfer-Encoding: 7bit\r\n' +
'\r\n' +
'MIME-Version: 1.0\r\n' +
'From: andris@kreata.ee\r\n' +
'To: andris@pangalink.net\r\n' +
'In-Reply-To: <test1>\r\n' +
'\r\n' +
'Hello world 1!\r\n' +
'------mailcomposer-?=_1-1328088797399\r\n' +
'Content-Type: message/rfc822\r\n' +
'Content-Transfer-Encoding: 7bit\r\n' +
'\r\n' +
'MIME-Version: 1.0\r\n' +
'From: andris@kreata.ee\r\n' +
'To: andris@pangalink.net\r\n' +
'\r\n' +
'Hello world 2!\r\n' +
'------mailcomposer-?=_1-1328088797399\r\n' +
'Content-Type: text/html; charset=utf-8\r\n' +
'Content-Transfer-Encoding: quoted-printable\r\n' +
'\r\n' +
'<b>Hello world 3!</b>\r\n' +
'------mailcomposer-?=_1-1328088797399--'
}, {
uid: 52,
flags: [],
modseq: 4,
internaldate: new Date(),
raw: 'from: sender@example.com\r\nto: to@example.com\r\ncc: cc@example.com\r\nsubject: test\r\n\r\nHello World!'
}, {
uid: 53,
flags: [],
modseq: 5,
internaldate: new Date()
}, {
uid: 60,
flags: [],
modseq: 6,
internaldate: new Date()
}],
journal: []
}, {
path: '[Gmail]/Sent Mail',
specialUse: '\\Sent',
uidValidity: 123,
uidNext: 90,
modifyIndex: 1,
messages: [],
journal: []
}].forEach(folder => {
folders.set(folder.path, folder);
subscriptions.add(folder);
});
// Setup server
let server = new IMAPServer(options);
server.notifier = new MemoryNotifier({
logger: {
info: () => false,
debug: () => false,
error: () => false
},
folders
});
server.on('error', err => {
console.log('SERVER ERR\n%s', err.stack); // eslint-disable-line no-console
});
server.onAuth = function (login, session, callback) {
if (login.username !== 'testuser' || login.password !== 'pass') {
return callback();
}
callback(null, {
user: {
username: login.username
}
});
};
// LIST "" "*"
// Returns all folders, query is informational
// folders is either an Array or a Map
server.onList = function (query, session, callback) {
this.logger.debug('[%s] LIST for "%s"', session.id, query);
callback(null, folders);
};
// LSUB "" "*"
// Returns all subscribed folders, query is informational
// folders is either an Array or a Map
server.onLsub = function (query, session, callback) {
this.logger.debug('[%s] LSUB for "%s"', session.id, query);
let subscribed = [];
folders.forEach(folder => {
if (subscriptions.has(folder)) {
subscribed.push(folder);
}
});
callback(null, subscribed);
};
// SUBSCRIBE "path/to/mailbox"
server.onSubscribe = function (mailbox, session, callback) {
this.logger.debug('[%s] SUBSCRIBE to "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
subscriptions.add(folders.get(mailbox));
callback(null, true);
};
// UNSUBSCRIBE "path/to/mailbox"
server.onUnsubscribe = function (mailbox, session, callback) {
this.logger.debug('[%s] UNSUBSCRIBE from "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
subscriptions.delete(folders.get(mailbox));
callback(null, true);
};
// CREATE "path/to/mailbox"
server.onCreate = function (mailbox, session, callback) {
this.logger.debug('[%s] CREATE "%s"', session.id, mailbox);
if (folders.has(mailbox)) {
return callback(null, 'ALREADYEXISTS');
}
folders.set(mailbox, {
path: mailbox,
uidValidity: Date.now(),
uidNext: 1,
modifyIndex: 0,
messages: [],
journal: []
});
subscriptions.add(folders.get(mailbox));
callback(null, true);
};
// RENAME "path/to/mailbox" "new/path"
// NB! RENAME affects child and hierarchy mailboxes as well, this example does not do this
server.onRename = function (mailbox, newname, session, callback) {
this.logger.debug('[%s] RENAME "%s" to "%s"', session.id, mailbox, newname);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
if (folders.has(newname)) {
return callback(null, 'ALREADYEXISTS');
}
let oldMailbox = folders.get(mailbox);
folders.delete(mailbox);
oldMailbox.path = newname;
folders.set(newname, oldMailbox);
callback(null, true);
};
// DELETE "path/to/mailbox"
server.onDelete = function (mailbox, session, callback) {
this.logger.debug('[%s] DELETE "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
// keep SPECIAL-USE folders
if (folders.get(mailbox).specialUse) {
return callback(null, 'CANNOT');
}
folders.delete(mailbox);
callback(null, true);
};
// SELECT/EXAMINE
server.onOpen = function (mailbox, session, callback) {
this.logger.debug('[%s] Opening "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
return callback(null, {
specialUse: folder.specialUse,
uidValidity: folder.uidValidity,
uidNext: folder.uidNext,
modifyIndex: folder.modifyIndex,
uidList: folder.messages.map(message => message.uid)
});
};
// STATUS (X Y X)
server.onStatus = function (mailbox, session, callback) {
this.logger.debug('[%s] Requested status for "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
return callback(null, {
messages: folder.messages.length,
uidNext: folder.uidNext,
uidValidity: folder.uidValidity,
highestModseq: folder.modifyIndex,
unseen: folder.messages.filter(message => message.flags.indexOf('\\Seen') < 0).length
});
};
// APPEND mailbox (flags) date message
server.onAppend = function (mailbox, flags, date, raw, session, callback) {
this.logger.debug('[%s] Appending message to "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'TRYCREATE');
}
date = date && new Date(date) || new Date();
let folder = folders.get(mailbox);
let message = {
uid: folder.uidNext++,
modseq: ++folder.modifyIndex,
date: date && new Date(date) || new Date(),
raw,
flags
};
folder.messages.push(message);
// do not write directly to stream, use notifications as the currently selected mailbox might not be the one that receives the message
this.notifier.addEntries(session.user.username, mailbox, {
command: 'EXISTS',
uid: message.uid
}, () => {
this.notifier.fire(session.user.username, mailbox);
return callback(null, true, {
uidValidity: folder.uidValidity,
uid: message.uid
});
});
};
// STORE / UID STORE, updates flags for selected UIDs
server.onUpdate = function (mailbox, update, session, callback) {
this.logger.debug('[%s] Updating messages in "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let condstoreEnabled = !!session.selected.condstoreEnabled;
let modified = [];
let folder = folders.get(mailbox);
let i = 0;
let processMessages = () => {
if (i >= folder.messages.length) {
this.notifier.fire(session.user.username, mailbox);
return callback(null, true, modified);
}
let message = folder.messages[i++];
let updated = false;
if (update.messages.indexOf(message.uid) < 0) {
return processMessages();
}
if (update.unchangedSince && message.modseq > update.unchangedSince) {
modified.push(message.uid);
return processMessages();
}
switch (update.action) {
case 'set':
// check if update set matches current or is different
if (message.flags.length !== update.value.length ||
update.value.filter(flag => message.flags.indexOf(flag) < 0).length) {
updated = true;
}
// set flags
message.flags = [].concat(update.value);
break;
case 'add':
message.flags = message.flags.concat(update.value.filter(flag => {
if (message.flags.indexOf(flag) < 0) {
updated = true;
return true;
}
return false;
}));
break;
case 'remove':
message.flags = message.flags.filter(flag => {
if (update.value.indexOf(flag) < 0) {
return true;
}
updated = true;
return false;
});
break;
}
// notifiy only if something changed
if (updated) {
message.modseq = ++folder.modifyIndex;
// Only show response if not silent or modseq is required
if (!update.silent || condstoreEnabled) {
session.writeStream.write(session.formatResponse('FETCH', message.uid, {
uid: update.isUid ? message.uid : false,
flags: update.silent ? false : message.flags,
modseq: condstoreEnabled ? message.modseq : false
}));
}
this.notifier.addEntries(session.user.username, mailbox, {
command: 'FETCH',
ignore: session.id,
uid: message.uid,
flags: message.flags
}, processMessages);
} else {
processMessages();
}
};
processMessages();
};
// EXPUNGE deletes all messages in selected mailbox marked with \Delete
server.onExpunge = function (mailbox, update, session, callback) {
this.logger.debug('[%s] Deleting messages from "%s"', session.id, mailbox);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
let deleted = [];
let i, len;
for (i = folder.messages.length - 1; i >= 0; i--) {
if (
(
(update.isUid && update.messages.indexOf(folder.messages[i].uid) >= 0) ||
!update.isUid
) && folder.messages[i].flags.indexOf('\\Deleted') >= 0) {
deleted.unshift(folder.messages[i].uid);
folder.messages.splice(i, 1);
}
}
let entries = [];
for (i = 0, len = deleted.length; i < len; i++) {
entries.push({
command: 'EXPUNGE',
ignore: session.id,
uid: deleted[i]
});
if (!update.silent) {
session.writeStream.write(session.formatResponse('EXPUNGE', deleted[i]));
}
}
this.notifier.addEntries(session.user.username, mailbox, entries, () => {
this.notifier.fire(session.user.username, mailbox);
return callback(null, true);
});
};
// COPY / UID COPY sequence mailbox
server.onCopy = function (mailbox, update, session, callback) {
this.logger.debug('[%s] Copying messages from "%s" to "%s"', session.id, mailbox, update.destination);
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
if (!folders.has(update.destination)) {
return callback(null, 'TRYCREATE');
}
let sourceFolder = folders.get(mailbox);
let destinationFolder = folders.get(update.destination);
let messages = [];
let sourceUid = [];
let destinationUid = [];
let i, len;
let entries = [];
for (i = sourceFolder.messages.length - 1; i >= 0; i--) {
if (update.messages.indexOf(sourceFolder.messages[i].uid) >= 0) {
messages.unshift(JSON.parse(JSON.stringify(sourceFolder.messages[i])));
sourceUid.unshift(sourceFolder.messages[i].uid);
}
}
for (i = 0, len = messages.length; i < len; i++) {
messages[i].uid = destinationFolder.uidNext++;
destinationUid.push(messages[i].uid);
destinationFolder.messages.push(messages[i]);
// do not write directly to stream, use notifications as the currently selected mailbox might not be the one that receives the message
entries.push({
command: 'EXISTS',
uid: messages[i].uid
});
}
this.notifier.addEntries(update.destination, session.user.username, entries, () => {
this.notifier.fire(update.destination, session.user.username);
return callback(null, true, {
uidValidity: destinationFolder.uidValidity,
sourceUid,
destinationUid
});
});
};
// sends results to socket
server.onFetch = function (mailbox, options, session, callback) {
this.logger.debug('[%s] Requested FETCH for "%s"', session.id, mailbox);
this.logger.debug('[%s] FETCH: %s', session.id, JSON.stringify(options.query));
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
let entries = [];
if (options.markAsSeen) {
// mark all matching messages as seen
folder.messages.forEach(message => {
if (options.messages.indexOf(message.uid) < 0) {
return;
}
// if BODY[] is touched, then add \Seen flag and notify other clients
if (message.flags.indexOf('\\Seen') < 0) {
message.flags.unshift('\\Seen');
entries.push({
command: 'FETCH',
ignore: session.id,
uid: message.uid,
flags: message.flags
});
}
});
}
this.notifier.addEntries(session.user.username, mailbox, entries, () => {
let pos = 0;
let processMessage = () => {
if (pos >= folder.messages.length) {
// once messages are processed show relevant updates
this.notifier.fire(session.user.username, mailbox);
return callback(null, true);
}
let message = folder.messages[pos++];
if (options.messages.indexOf(message.uid) < 0) {
return setImmediate(processMessage);
}
if (options.changedSince && message.modseq <= options.changedSince) {
return setImmediate(processMessage);
}
let stream = imapHandler.compileStream(session.formatResponse('FETCH', message.uid, {
query: options.query,
values: session.getQueryResponse(options.query, message)
}));
// send formatted response to socket
session.writeStream.write(stream, () => {
setImmediate(processMessage);
});
};
setImmediate(processMessage);
});
};
// returns an array of matching UID values and the highest modseq of matching messages
server.onSearch = function (mailbox, options, session, callback) {
if (!folders.has(mailbox)) {
return callback(null, 'NONEXISTENT');
}
let folder = folders.get(mailbox);
let highestModseq = 0;
let uidList = folder.messages.filter(message => {
let match = session.matchSearchQuery(message, options.query);
if (match && highestModseq < message.modseq) {
highestModseq = message.modseq;
}
return match;
}).map(message => message.uid);
callback(null, {
uidList,
highestModseq
});
};
return server;
};

View file

@ -0,0 +1,22 @@
/*eslint no-unused-expressions: 0, prefer-arrow-callback: 0 */
'use strict';
let imapTools = require('../lib/imap-tools');
let chai = require('chai');
let expect = chai.expect;
chai.config.includeStack = true;
describe('#packMessageRange', function () {
it('should return as is', function () {
expect(imapTools.packMessageRange([1, 3, 5, 9])).to.equal('1,3,5,9');
});
it('should return a range', function () {
expect(imapTools.packMessageRange([1, 2, 3, 4])).to.equal('1:4');
});
it('should return mixed ranges', function () {
expect(imapTools.packMessageRange([1, 3, 4, 6, 8, 9, 10, 11, 13])).to.equal('1,3:4,6,8:11,13');
});
});

196
imap-notifier.js Normal file
View file

@ -0,0 +1,196 @@
'use strict';
const crypto = require('crypto');
const EventEmitter = require('events').EventEmitter;
class ImapNotifier extends EventEmitter {
constructor(options) {
super();
this.database = options.database;
let logfunc = (...args) => {
let level = args.shift() || 'DEBUG';
let message = args.shift() || '';
console.log([level].concat(message || '').join(' '), ...args); // eslint-disable-line no-console
};
this.logger = options.logger || {
info: logfunc.bind(null, 'INFO'),
debug: logfunc.bind(null, 'DEBUG'),
error: logfunc.bind(null, 'ERROR')
};
this._listeners = new EventEmitter();
this._listeners.setMaxListeners(0);
EventEmitter.call(this);
}
/**
* Generates hashed event names for mailbox:username pairs
*
* @param {String} path
* @param {String} username
* @returns {String} md5 hex
*/
_eventName(path, username) {
return crypto.createHash('md5').update(username + ':' + path).digest('hex');
}
/**
* Registers an event handler for path:username events
*
* @param {String} username
* @param {String} path
* @param {Function} handler Function to run once there are new entries in the journal
*/
addListener(session, path, handler) {
let eventName = this._eventName(session.user.username, path);
this._listeners.addListener(eventName, handler);
this.logger.debug('New journal listener for %s ("%s:%s")', eventName, session.user.username, path);
}
/**
* Unregisters an event handler for path:username events
*
* @param {String} username
* @param {String} path
* @param {Function} handler Function to run once there are new entries in the journal
*/
removeListener(session, path, handler) {
let eventName = this._eventName(session.user.username, path);
this._listeners.removeListener(eventName, handler);
this.logger.debug('Removed journal listener from %s ("%s:%s")', eventName, session.user.username, path);
}
/**
* Stores multiple journal entries to db
*
* @param {String} username
* @param {String} path
* @param {Array|Object} entries An array of entries to be journaled
* @param {Function} callback Runs once the entry is either stored or an error occurred
*/
addEntries(username, path, entries, callback) {
if (entries && !Array.isArray(entries)) {
entries = [entries];
} else if (!entries || !entries.length) {
return callback(null, false);
}
this.database.collection('mailboxes').findOneAndUpdate({
username,
path
}, {
$inc: {
modifyIndex: entries.length
}
}, {}, (err, item) => {
if (err) {
return callback(err);
}
if (!item || !item.value) {
// was not able to acquire a lock
return callback(null, new Error('Selected mailbox does not exist'));
}
let mailbox = item.value;
let startIndex = mailbox.modifyIndex;
let updated = 0;
let updateNext = () => {
if (updated >= entries.length) {
return this.database.collection('journal').insertMany(entries, {
w: 1,
ordered: false
}, (err, r) => {
if (err) {
return callback(err);
}
return callback(null, r.insertedCount);
});
}
let entry = entries[updated++];
entry.mailbox = mailbox._id;
entry.modseq = ++startIndex;
if (entry.message) {
this.database.collection('messages').findOneAndUpdate({
_id: entry.message,
modseq: {
$lt: entry.modseq
}
}, {
$set: {
modseq: entry.modseq
}
}, {}, err => {
if (err) {
this.logger.error('Error updating modseq for message %s. %s', entry.message, err.message);
}
updateNext();
});
} else {
updateNext();
}
};
updateNext();
});
}
/**
* Sends a notification that there are new updates in the selected mailbox
*
* @param {String} username
* @param {String} path
*/
fire(username, path, payload) {
let eventName = this._eventName(username, path);
setImmediate(() => {
this._listeners.emit(eventName, payload);
});
}
/**
* Returns all entries from the journal that have higher than provided modification index
*
* @param {String} session
* @param {String} path
* @param {Number} modifyIndex Last known modification id
* @param {Function} callback Returns update entries as an array
*/
getUpdates(session, path, modifyIndex, callback) {
modifyIndex = Number(modifyIndex) || 0;
let username = session.user.username;
this.database.collection('mailboxes').findOne({
username,
path
}, (err, mailbox) => {
if (err) {
return callback(err);
}
if (!mailbox) {
return callback(null, 'NONEXISTENT');
}
this.database.collection('journal').find({
mailbox: mailbox._id,
modseq: {
$gt: modifyIndex
}
}).toArray(callback);
});
}
}
module.exports = ImapNotifier;

66
indexes.js Normal file
View file

@ -0,0 +1,66 @@
/* global db */
'use strict';
db.users.createIndex({
username: 1
});
db.mailboxes.createIndex({
username: 1
});
db.mailboxes.createIndex({
username: 1,
path: 1
});
db.mailboxes.createIndex({
username: 1,
subscribed: 1
});
db.messages.createIndex({
mailbox: 1
});
db.messages.createIndex({
mailbox: 1,
unseen: 1
});
db.messages.createIndex({
mailbox: 1,
uid: 1
});
db.messages.createIndex({
mailbox: 1,
uid: 1,
modseq: 1
});
db.messages.createIndex({
mailbox: 1,
flags: 1
});
db.messages.createIndex({
modseq: 1
});
db.messages.createIndex({
modseq: -1
});
db.messages.createIndex({
flags: 1
});
db.messages.createIndex({
date: 1
});
db.messages.createIndex({
date: -1
});
db.messages.createIndex({
uid: 1
});
db.messages.createIndex({
uid: -1
});

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "wildduck",
"version": "1.0.0",
"description": "IMAP server built with Node.js and MongoDB",
"main": "index.js",
"scripts": {
"test": "grunt"
},
"keywords": [],
"author": "",
"license": "EUPL-1.1",
"devDependencies": {
"chai": "^3.5.0",
"eslint-config-nodemailer": "^1.0.0",
"grunt": "^1.0.1",
"grunt-cli": "^1.2.0",
"grunt-mocha-test": "^0.13.2",
"mocha": "^3.0.2",
"grunt-eslint": "^19.0.0"
},
"dependencies": {
"addressparser": "^1.0.1",
"clone": "^1.0.2",
"libbase64": "^0.1.0",
"nodemailer-fetch": "^1.6.0",
"utf7": "^1.0.2",
"bcryptjs": "^2.4.3",
"config": "^1.25.1",
"mailparser": "^2.0.2",
"mongodb": "^2.2.24",
"npmlog": "^4.0.2",
"redis": "^2.6.5",
"smtp-server": "^2.0.2",
"uuid": "^3.0.1",
"toml": "^2.3.2"
},
"repository": {
"type": "git",
"url": "git://github.com/wildduck-email/wildduck.git"
}
}

1148
server.js Normal file

File diff suppressed because it is too large Load diff