1
0
mirror of https://github.com/node-red/node-red-nodes.git synced 2023-10-10 13:36:58 +02:00

XOAuth2 for Email-In node (#975)

* Update PULL_REQUEST_TEMPLATE.md

* Add new UI elements to Email In node

Locale for en-US
Added Auth type and Token field to Email IN
Dynamically appear based on selection

* XOAUTH2 IMAP

Minor UI changes. Exposing only XOAuth2. Picks up raw access token from input message specified.

Only works for IMAP
Token formatted by node for Exchange and GMail, won't work on other providers.
Only works on trigger, not timer

TODO:
Add POP XOAUTH2 capability
Add SMTP XOAUTH2 capability
Add option to pass SASL XAOUTH2 token rather than raw OAUTH2 token

* SASL Format

Added checkbox to turn off SASL formatting if the user wants to do this themselves

* XOAuth2 forces input

Using XOauth2 forces triggered node, and automatic trigger sets auth to basic;
XOAuth2 needs token from flow

* Error reporting

Password missing error only occurs if set to basic authentication.

Token missing only occurs if set to XOAuth2.

* Unit tests

Make sure basic authentication is selected by default, and that an additional input is created and timed triggers are turned off for XOauth2

* Cleanup and README

Remove old code, update readme

* XOauth2 IMAP Release

Prevent XOAuth2 being used for POP. Update PR Template.
Updated help file.
Bumped version to 1.19-beta

* Update POP3 dependency

Removed dependency to poplib.js, moved to node-pop3. Re-wrote checkPOP3 function asynchronously using the new library. Added some node.status changes to mimic IMAP behaviour.

* XOAUTH2 POP3

Added checking for authentication type to allow XOauth2 tokens to be sent to POP server. Turned off UI restrictions for this functionality.

* XOAUTH2 POP3 Release

Updated help docs and version to reflect changes.

* Add new UI elements to Email Out node

Add option for XAouth2 for SMTP node

* XOAUTH2 SMTP

Exposing functionality for OAuth2 through Nodemailer. Added some error reporting if credentials are missing to match the Email-In node.

* XOAUTH2 SMTP Release

Updated help file to reflect changes.

* Unit Tests for Email Out

Modified tests to allow these unit tests to pass, but does not address the fault caused by the Node Test Helper - credentials only loaded after the flow has been loaded.

---------

Co-authored-by: Dave Conway-Jones <dceejay@users.noreply.github.com>
This commit is contained in:
wooferguy 2023-03-28 08:27:47 +13:00 committed by GitHub
parent 497270ba74
commit 9a57958a1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 383 additions and 1031 deletions

View File

@ -11,7 +11,7 @@ Put an `x` in the boxes that apply
-->
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [x] New feature (non-breaking change which adds functionality)
<!--
If you want to raise a pull-request with a new feature, or a refactoring
@ -25,10 +25,13 @@ the [forum](https://discourse.nodered.org) or
<!-- Describe the nature of this change. What problem does it address? -->
Adds authentication option to the Email node (node-red-node-email) to use OAuth and XOAuth2
********** This version: IMAP ONLY **********
## Checklist
<!-- Put an `x` in the boxes that apply -->
- [ ] I have read the [contribution guidelines](https://github.com/node-red/node-red-nodes/blob/master/CONTRIBUTING.md)
- [ ] For non-bugfix PRs, I have discussed this change on the forum/slack team.
- [ ] I have run `grunt` to verify the unit tests pass
- [ ] I have added suitable unit tests to cover the new/changed functionality
- [x] I have read the [contribution guidelines](https://github.com/node-red/node-red-nodes/blob/master/CONTRIBUTING.md)
- [x] For non-bugfix PRs, I have discussed this change on the forum/slack team.
- [x] I have run `grunt` to verify the unit tests pass
- [x] I have added suitable unit tests to cover the new/changed functionality

View File

@ -49,7 +49,7 @@
"node-red": "^2.1.4",
"node-red-node-test-helper": "^0.2.7",
"nodemailer": "^6.7.2",
"poplib": "^0.1.7",
"node-pop3": "^0.8.0",
"proxyquire": "^2.1.3",
"pushbullet": "^2.4.0",
"sentiment": "^2.1.0",

View File

@ -40,13 +40,29 @@
<span data-i18n="email.label.useSecureConnection"></span>
</div>
<div class="form-row">
<label for="node-input-authtype"><i class="fa fa-tasks"></i> <span data-i18n="email.label.authtype"></span></label>
<select type="text" id="node-input-authtype">
<option value="BASIC">Basic</option>
<option value="XOAUTH2">XOAuth2</option>
</select>
</div>
<div class="form-row node-input-userid">
<label for="node-input-userid"><i class="fa fa-user"></i> <span data-i18n="email.label.userid"></span></label>
<input type="text" id="node-input-userid">
</div>
<div class="form-row">
<div class="form-row node-input-password">
<label for="node-input-password"><i class="fa fa-lock"></i> <span data-i18n="email.label.password"></span></label>
<input type="password" id="node-input-password">
</div>
<div class="form-row node-input-saslformat" style="display: none;">
<label for="node-input-saslformat"><i class="fa fa-code"></i> <span data-i18n="email.label.saslformat"></span></label>
<input type="checkbox" id="node-input-saslformat" style="width: auto;">
</div>
<div class="form-row node-input-token" style="display: none;">
<label for="node-input-token"><i class="fa fa-lock"></i> <span data-i18n="email.label.token"></span></label>
<input type="text" id="node-input-token" placeholder="oauth2Response.access_token">
</div>
<br/>
<div class="form-row">
<label for="node-input-useTLS"><i class="fa fa-lock"></i> <span data-i18n="email.label.useTLS"></label>
@ -58,6 +74,22 @@
<input type="text" id="node-input-dname" data-i18n="[placeholder]node-red:common.label.name">
</div>
<div class="form-tips" id="node-tip"><span data-i18n="[html]email.tip.cred"></span></div>
<script>
$("#node-input-authtype").change(function() {
var protocol = $("#node-input-authtype").val();
if (protocol === "BASIC") {
$(".node-input-password").show();
$(".node-input-saslformat").hide();
$(".node-input-token").hide();
} else {
$(".node-input-password").hide();
$(".node-input-saslformat").show();
$(".node-input-token").show();
$("#node-input-fetch").val("trigger");
$("#node-input-fetch").change();
}
});
</script>
</script>
<script type="text/javascript">
@ -68,6 +100,9 @@
defaults: {
server: {value:"smtp.gmail.com",required:true},
port: {value:"465",required:true},
authtype: {value: "BASIC"},
saslformat: {value: true},
token: {value: "oauth2Response.access_token"},
secure: {value: true},
tls: {value: true},
name: {value:""},
@ -97,6 +132,10 @@
} else {
$('#node-tip').hide();
}
$("#node-input-token").typedInput({
type:'msg',
types:['msg']
});
}
});
})();
@ -143,13 +182,28 @@
<input type="text" id="node-input-port" placeholder="993">
</div>
<div class="form-row">
<label for="node-input-authtype"><i class="fa fa-tasks"></i> <span data-i18n="email.label.authtype"></span></label>
<select type="text" id="node-input-authtype">
<option value="BASIC">Basic</option>
<option value="XOAUTH2">XOAuth2</option>
</select>
</div>
<div class="form-row node-input-userid">
<label for="node-input-userid"><i class="fa fa-user"></i> <span data-i18n="email.label.userid"></span></label>
<input type="text" id="node-input-userid">
</div>
<div class="form-row">
<div class="form-row node-input-password">
<label for="node-input-password"><i class="fa fa-lock"></i> <span data-i18n="email.label.password"></span></label>
<input type="password" id="node-input-password">
</div>
<div class="form-row node-input-saslformat" style="display: none;">
<label for="node-input-saslformat"><i class="fa fa-code"></i> <span data-i18n="email.label.saslformat"></span></label>
<input type="checkbox" id="node-input-saslformat" style="width: auto;">
</div>
<div class="form-row node-input-token" style="display: none;">
<label for="node-input-token"><i class="fa fa-lock"></i> <span data-i18n="email.label.token"></span></label>
<input type="text" id="node-input-token" placeholder="oauth2Response.access_token">
</div>
<div class="form-row node-input-box">
<label for="node-input-box"><i class="fa fa-inbox"></i> <span data-i18n="email.label.folder"></span></label>
<input type="text" id="node-input-box">
@ -222,6 +276,21 @@
}
checkPorts();
});
$("#node-input-authtype").change(function() {
var protocol = $("#node-input-authtype").val();
if (protocol === "BASIC") {
$(".node-input-password").show();
$(".node-input-saslformat").hide();
$(".node-input-token").hide();
} else {
$(".node-input-password").hide();
$(".node-input-saslformat").show();
$(".node-input-token").show();
$("#node-input-fetch").val("trigger");
$("#node-input-fetch").change();
}
});
</script>
</script>
@ -237,6 +306,9 @@
useSSL: {value: true},
autotls: {value: "never"},
port: {value:"993",required:true},
authtype: {value: "BASIC"},
saslformat: {value: true},
token: {value: "oauth2Response.access_token"},
box: {value:"INBOX"}, // For IMAP, The mailbox to process
disposition: { value: "Read" }, // For IMAP, the disposition of the read email
criteria: {value: "UNSEEN"},
@ -289,6 +361,8 @@
else {
$('#node-repeatTime').show();
that.inputs = 0;
$("#node-input-authtype").val("BASIC");
$("#node-input-authtype").change();
}
});
$("#node-input-criteria").change(function() {
@ -297,6 +371,10 @@
$("#node-input-fetch").change();
}
});
$("#node-input-token").typedInput({
type:'msg',
types:['msg']
});
}
});
})();

View File

@ -6,7 +6,7 @@ const { domainToUnicode } = require("url");
* POP3 protocol - RFC1939 - https://www.ietf.org/rfc/rfc1939.txt
*
* Dependencies:
* * poplib - https://www.npmjs.com/package/poplib
* * node-pop3 - https://www.npmjs.com/package/node-pop3
* * nodemailer - https://www.npmjs.com/package/nodemailer
* * imap - https://www.npmjs.com/package/imap
* * mailparser - https://www.npmjs.com/package/mailparser
@ -16,7 +16,7 @@ module.exports = function(RED) {
"use strict";
var util = require("util");
var Imap = require('imap');
var POP3Client = require("./poplib.js");
var Pop3Command = require("node-pop3");
var nodemailer = require("nodemailer");
var simpleParser = require("mailparser").simpleParser;
var SMTPServer = require("smtp-server").SMTPServer;
@ -42,20 +42,38 @@ module.exports = function(RED) {
this.secure = n.secure;
this.tls = true;
var flag = false;
this.authtype = n.authtype || "BASIC";
if (this.authtype !== "BASIC") {
this.inputs = 1;
this.repeat = 0;
}
if (this.credentials && this.credentials.hasOwnProperty("userid")) {
this.userid = this.credentials.userid;
} else {
if (globalkeys) {
this.userid = globalkeys.user;
flag = true;
} else {
this.error(RED._("email.errors.nouserid"));
}
}
if(this.authtype === "BASIC" ) {
if (this.credentials && this.credentials.hasOwnProperty("password")) {
this.password = this.credentials.password;
} else {
if (globalkeys) {
this.password = globalkeys.pass;
flag = true;
} else {
this.error(RED._("email.errors.nopassword"));
}
}
} else {
this.saslformat = n.saslformat;
if(n.token!=="") {
this.token = n.token;
} else {
this.error(RED._("email.errors.notoken"));
}
}
if (flag) {
@ -71,11 +89,26 @@ module.exports = function(RED) {
tls: {rejectUnauthorized: node.tls}
}
if (this.userid && this.password) {
if(node.authtype === "BASIC" ) {
smtpOptions.auth = {
user: node.userid,
pass: node.password
};
} else if(node.authtype == "XOAUTH2") {
var value = RED.util.getMessageProperty(msg,node.token);
if (value !== undefined) {
if(node.saslformat) {
//Make base64 string for access - compatible with outlook365 and gmail
saslxoauth2 = Buffer.from("user="+node.userid+"\x01auth=Bearer "+value+"\x01\x01").toString('base64');
} else {
saslxoauth2 = value;
}
}
smtpOptions.auth = {
type: "OAuth2",
user: node.userid,
accessToken: saslxoauth2
};
}
var smtpTransport = nodemailer.createTransport(smtpOptions);
@ -178,6 +211,7 @@ module.exports = function(RED) {
// Setup the EmailInNode
function EmailInNode(n) {
var imap;
var pop3;
RED.nodes.createNode(this,n);
this.name = n.name;
@ -200,6 +234,11 @@ module.exports = function(RED) {
this.protocol = n.protocol || "IMAP";
this.disposition = n.disposition || "None"; // "None", "Delete", "Read"
this.criteria = n.criteria || "UNSEEN"; // "ALL", "ANSWERED", "FLAGGED", "SEEN", "UNANSWERED", "UNFLAGGED", "UNSEEN"
this.authtype = n.authtype || "BASIC";
if (this.authtype !== "BASIC") {
this.inputs = 1;
this.repeat = 0;
}
var flag = false;
@ -213,6 +252,7 @@ module.exports = function(RED) {
this.error(RED._("email.errors.nouserid"));
}
}
if(this.authtype === "BASIC" ) {
if (this.credentials && this.credentials.hasOwnProperty("password")) {
this.password = this.credentials.password;
} else {
@ -223,6 +263,14 @@ module.exports = function(RED) {
this.error(RED._("email.errors.nopassword"));
}
}
} else {
this.saslformat = n.saslformat;
if(n.token!=="") {
this.token = n.token;
} else {
this.error(RED._("email.errors.notoken"));
}
}
if (flag) {
RED.nodes.addCredentials(n.id,{userid:this.userid, password:this.password, global:true});
}
@ -257,74 +305,63 @@ module.exports = function(RED) {
// Check the POP3 email mailbox for any new messages. For any that are found,
// retrieve each message, call processNewMessage to process it and then delete
// the messages from the server.
function checkPOP3(msg,send,done) {
var currentMessage;
var maxMessage;
//node.log("Checking POP3 for new messages");
async function checkPOP3(msg,send,done) {
var tout = (node.repeat > 0) ? node.repeat - 500 : 15000;
var saslxoauth2 = "";
var currentMessage = 1;
var maxMessage = 0;
var nextMessage;
// Form a new connection to our email server using POP3.
var pop3Client = new POP3Client(
node.inport, node.inserver,
{enabletls: node.useSSL} // Should we use SSL to connect to our email server?
);
pop3 = new Pop3Command({
"host": node.inserver,
"tls": node.useSSL,
"timeout": tout,
"port": node.inport
});
try {
node.status({fill:"grey",shape:"dot",text:"node-red:common.status.connecting"});
await pop3.connect();
if(node.authtype == "XOAUTH2") {
var value = RED.util.getMessageProperty(msg,node.token);
if (value !== undefined) {
if(node.saslformat) {
//Make base64 string for access - compatible with outlook365 and gmail
saslxoauth2 = Buffer.from("user="+node.userid+"\x01auth=Bearer "+value+"\x01\x01").toString('base64');
} else {
saslxoauth2 = value;
}
}
await pop3.command('AUTH', "XOAUTH2");
await pop3.command(saslxoauth2);
// If we have a next message to retrieve, ask to retrieve it otherwise issue a
// quit request.
function nextMessage() {
if (currentMessage > maxMessage) {
pop3Client.quit();
} else if(node.authtype == "BASIC") {
await pop3.command('USER', node.userid);
await pop3.command('PASS', node.password);
}
} catch(err) {
node.error(err.message,err);
node.status({fill:"red",shape:"ring",text:"email.status.connecterror"});
setInputRepeatTimeout();
done();
return;
}
pop3Client.retr(currentMessage);
currentMessage++;
} // End of nextMessage
pop3Client.on("stat", function(status, data) {
// Data contains:
// {
// count: <Number of messages to be read>
// octect: <size of messages to be read>
// }
if (status) {
currentMessage = 1;
maxMessage = data.count;
nextMessage();
} else {
node.log(util.format("stat error: %s %j", status, data));
}
});
pop3Client.on("error", function(err) {
setInputRepeatTimeout();
node.log("error: " + JSON.stringify(err));
done();
});
pop3Client.on("connect", function() {
//node.log("We are now connected");
pop3Client.login(node.userid, node.password);
});
pop3Client.on("login", function(status, rawData) {
//node.log("login: " + status + ", " + rawData);
if (status) {
pop3Client.stat();
} else {
node.log(util.format("login error: %s %j", status, rawData));
pop3Client.quit();
maxMessage = (await pop3.STAT()).split(" ")[0];
if(maxMessage>0) {
node.status({fill:"blue", shape:"dot", text:"email.status.fetching"});
while(currentMessage<=maxMessage) {
try {
nextMessage = await pop3.RETR(currentMessage);
} catch(err) {
node.error(RED._("email.errors.fetchfail", err.message),err);
node.status({fill:"red",shape:"ring",text:"email.status.fetcherror"});
setInputRepeatTimeout();
done();
return;
}
});
pop3Client.on("retr", function(status, msgNumber, data, rawData) {
// node.log(util.format("retr: status=%s, msgNumber=%d, data=%j", status, msgNumber, data));
if (status) {
try {
// We have now received a new email message. Create an instance of a mail parser
// and pass in the email message. The parser will signal when it has parsed the message.
simpleParser(data, {}, function(err, parsed) {
simpleParser(nextMessage, {}, function(err, parsed) {
//node.log(util.format("simpleParser: on(end): %j", mailObject));
if (err) {
node.status({fill:"red", shape:"ring", text:"email.status.parseerror"});
@ -334,29 +371,24 @@ module.exports = function(RED) {
processNewMessage(msg, parsed);
}
});
pop3Client.dele(msgNumber);
//processNewMessage(msg, nextMessage);
} catch(err) {
node.error(RED._("email.errors.parsefail", {folder:node.box}), err);
node.status({fill:"red",shape:"ring",text:"email.status.parseerror"});
setInputRepeatTimeout();
done();
return;
}
else {
node.log(util.format("retr error: %s %j", status, rawData));
pop3Client.quit();
await pop3.DELE(currentMessage);
currentMessage++;
}
await pop3.QUIT();
node.status({fill:"green",shape:"dot",text:"finished"});
setTimeout(status_clear, 5000);
setInputRepeatTimeout();
done();
}
});
pop3Client.on("invalid-state", function(cmd) {
node.log("Invalid state: " + cmd);
});
pop3Client.on("locked", function(cmd) {
node.log("We were locked: " + cmd);
});
// When we have deleted the last processed message, we can move on to
// processing the next message.
pop3Client.on("dele", function(status, msgNumber) {
nextMessage();
});
} // End of checkPOP3
@ -367,6 +399,49 @@ module.exports = function(RED) {
var s = false;
var ss = false;
function checkIMAP(msg,send,done) {
var tout = (node.repeat > 0) ? node.repeat - 500 : 15000;
var saslxoauth2 = "";
if(node.authtype == "XOAUTH2") {
var value = RED.util.getMessageProperty(msg,node.token);
if (value !== undefined) {
if(node.saslformat) {
//Make base64 string for access - compatible with outlook365 and gmail
saslxoauth2 = Buffer.from("user="+node.userid+"\x01auth=Bearer "+value+"\x01\x01").toString('base64');
} else {
saslxoauth2 = value;
}
}
imap = new Imap({
xoauth2: saslxoauth2,
host: node.inserver,
port: node.inport,
tls: node.useSSL,
autotls: node.autotls,
tlsOptions: { rejectUnauthorized: false },
connTimeout: tout,
authTimeout: tout
});
} else {
imap = new Imap({
user: node.userid,
password: node.password,
host: node.inserver,
port: node.inport,
tls: node.useSSL,
autotls: node.autotls,
tlsOptions: { rejectUnauthorized: false },
connTimeout: tout,
authTimeout: tout
});
}
imap.on('error', function(err) {
if (err.errno !== "ECONNRESET") {
s = false;
node.error(err.message,err);
node.status({fill:"red",shape:"ring",text:"email.status.connecterror"});
}
setInputRepeatTimeout();
});
//console.log("Checking IMAP for new messages");
// We get back a 'ready' event once we have connected to imap
s = true;
@ -521,29 +596,6 @@ module.exports = function(RED) {
}
} // End of checkEmail
if (node.protocol === "IMAP") {
var tout = (node.repeat > 0) ? node.repeat - 500 : 15000;
imap = new Imap({
user: node.userid,
password: node.password,
host: node.inserver,
port: node.inport,
tls: node.useSSL,
autotls: node.autotls,
tlsOptions: { rejectUnauthorized: false },
connTimeout: tout,
authTimeout: tout
});
imap.on('error', function(err) {
if (err.errno !== "ECONNRESET") {
s = false;
node.error(err.message,err);
node.status({fill:"red",shape:"ring",text:"email.status.connecterror"});
}
setInputRepeatTimeout();
});
}
node.on("input", function(msg, send, done) {
send = send || function() { node.send.apply(node,arguments) };
checkEmail(msg,send,done);
@ -560,6 +612,10 @@ module.exports = function(RED) {
}
});
function status_clear() {
node.status({});
}
function setInputRepeatTimeout() {
// Set the repetition timer as needed
if (!isNaN(node.repeat) && node.repeat > 0) {

View File

@ -9,6 +9,8 @@ Pre-requisite
You will need valid email credentials for your email server. For GMail this may mean
getting an application password if you have two-factor authentication enabled.
For Exchange and Outlook 365 you must use OAuth2.0.
**Note :** Version 1.x of this node requires **Node.js v8** or newer.
Install
@ -26,6 +28,13 @@ GMail users
If you are accessing GMail you may need to either enable <a target="_new" href="https://support.google.com/mail/answer/185833?hl=en">an application password</a>,
or enable <a target="_new" href="https://support.google.com/accounts/answer/6010255?hl=en">less secure access</a> via your Google account settings.</p>
Office 365 users
-----------
If you are accessing Exchnage you will need to register an application through their platform and use OAuth2.0.
<a target="_new" href="https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#get-an-access-token">Details on how to do this can be found here.</a>
Usage
-----
@ -42,6 +51,9 @@ If there is text/html then that is returned in `msg.html`. `msg.from` and
Additionally `msg.header` contains the complete header object including
**to**, **cc** and other potentially useful properties.
Modern authentication through OAuth2.0 is supported, but must be triggered by an incoming access token and
can only be automatically triggered upstream.
### Output node
Sends the `msg.payload` as an email, with a subject of `msg.topic`.

View File

@ -8,6 +8,16 @@
default value.</p>
<h3>Gmail users</h3>
<p>If you are accessing Gmail you may need to either enable <a target="_new" href="https://support.google.com/mail/answer/185833?hl=en">an application password</a>.</p>
<h3>Authentication</h3>
<p>When connecting to a SMTP server, two authentication types are available: Basic and XOAuth2.</p>
<ul>
<li><b>Basic:</b> requires a username and password to be entered</li>
<li><b>XOAuth2:</b> requires a username and a <code>msg</code> property to extract the access token</li>
</ul>
<h3>SASL Formatting:</h3>
<p>SASL XOAuth2 tokens are created by combining the username and token, encoding it in base64, and passing it to the mail server in the following format:</p>
<pre>base64("user=" + userName + "^Aauth=Bearer " + accessToken + "^A^A")</pre>
<p>If the checkbox is unticked, flow creators can format the token themselves before passing it to the node.</p>
<h3>Details</h3>
<p>The payload can be html format. You may supply a separate plaintext version using <code>msg.plaintext</code>.
If you don't and <code>msg.payload</code> contains html, it will also be used for the plaintext.
@ -25,21 +35,32 @@
</script>
<script type="text/html" data-help-name="e-mail in">
<p>Repeatedly gets emails from a POP3 or IMAP server and forwards on as a msg if not already seen.</p>
<p>The subject is loaded into <code>msg.topic</code> and <code>msg.payload</code> is the plain text body.
If there is text/html then that is returned in <code>msg.html</code>. <code>msg.from</code> and <code>msg.date</code> are also set if you need them.</p>
<p>Additionally <code>msg.header</code> contains the complete header object including
<i>to</i>, <i>cc</i> and other potentially useful properties.</p>
<p>It can optionally mark the message as Read (default), Delete it, or leave unmarked (None).</p>
<p>Uses the <a href="https://github.com/mscdex/node-imap/blob/master/README.md" target="_new">node-imap module</a> - see that page for
information on the <code>msg.criteria</code> format if needed.</p>
<p>Any attachments supplied in the incoming email can be found in the <code>msg.attachments</code> property. This will be an array of objects where
each object represents a specific attachments. The format of the object is:</p>
<h3>Overview</h3>
<p>The e-mail in node retrieves emails from a POP3 or IMAP server and forwards the email data as a message if it has not already been seen.</p>
<h3>Message Properties</h3>
<p>The following properties are set on the message object:</p>
<ul>
<li><code>msg.topic</code> - the subject of the email</li>
<li><code>msg.payload</code> - the plain text body of the email</li>
<li><code>msg.html</code> - the HTML body of the email (if present)</li>
<li><code>msg.from</code> - the sender of the email</li>
<li><code>msg.date</code> - the date the email was sent</li>
<li><code>msg.header</code> - the complete header object including information such as the "to" and "cc" recipients</li>
<li><code>msg.attachments</code> - an array of objects representing any attachments included in the email</li>
</ul>
<h3>Module Used</h3>
<p>The e-mail in node uses the <a href="https://github.com/mscdex/node-imap/blob/master/README.md" target="_new">node-imap module</a>, see that page for information on the <code>msg.criteria</code> format if needed.</p>
<p>It also makes use of <a href="https://github.com/node-pop3/node-pop3#readme" target="_new">node-pop3 module</a></p>
<h3>Attachment Format</h3>
<p>Each object in the <code>msg.attachments</code> array is formatted as follows:</p>
<pre>
{
contentType: // The MIME content description
fileName: // A suggested file name associated with this attachment
transferEncoding: // How was the original email attachment encodded?
transferEncoding: // How was the original email attachment encoded?
contentDisposition: // Unknown
generatedFileName: // A suggested file name associated with this attachment
contentId: // A unique generated ID for this attachment
@ -49,9 +70,24 @@
// We can turn this into a base64 data string with content.toString('base64')
}
</pre>
<p><b>Note</b>: For POP3, the default port numbers are 110 for plain TCP and 995 for SSL. For IMAP the port numbers are 143 for plain TCP and 993 for SSL.</p>
<p><b>Note</b>: With option 'STARTTLS' an established plain connection is upgraded to an encrypted one. Set to 'always' to always attempt connection upgrades via STARTTLS, 'required' only if upgrading is required, or 'never' to never attempt upgrading.</p>
<p><b>Note</b>: The maximum refresh interval is 2147483 seconds (24.8 days).</p>
<h3>Authentication</h3>
<p>When connecting to a POP3 or IMAP server, two authentication types are available: Basic and XOAuth2.</p>
<ul>
<li><b>Basic:</b> requires a username and password to be entered</li>
<li><b>XOAuth2:</b> requires a username and a <code>msg</code> property to extract the access token</li>
</ul>
<p>With XOAuth2 authentication, periodic fetching is not available. The node will only attemp to login when a new token is receieved.</p>
<h3>SASL Formatting:</h3>
<p>SASL XOAuth2 tokens are created by combining the username and token, encoding it in base64, and passing it to the mail server in the following format:</p>
<pre>base64("user=" + userName + "^Aauth=Bearer " + accessToken + "^A^A")</pre>
<p>If the checkbox is unticked, flow creators can format the token themselves before passing it to the node.</p>
<h3>Notes</h3>
<ul>
<li>For POP3, the default port numbers are 110 for plain TCP and 995 for SSL. For IMAP the port numbers are 143 for plain TCP and 993 for SSL.</li>
<li>With option 'STARTTLS' an established plain connection is upgraded to an encrypted one. Set to 'always' to always attempt connection upgrades via STARTTLS, 'required' only if upgrading is required, or 'never' to never attempt upgrading.</li>
<li>The maximum refresh interval is 2147483 seconds (24.8 days).</li>
</ul>
</script>

View File

@ -31,6 +31,9 @@
"unflagged": "Unflagged",
"unseen": "Unseen",
"autotls": "Start TLS?",
"authtype": "Auth type",
"saslformat": "Format to SASL",
"token": "Token",
"never": "never",
"required": "if required",
"always": "always",
@ -69,6 +72,7 @@
"errors": {
"nouserid": "No e-mail userid set",
"nopassword": "No e-mail password set",
"notoken": "No token property set",
"nocredentials": "No Email credentials found. See info panel.",
"nosmtptransport": "No SMTP transport. See info panel.",
"nopayload": "No payload to send",

View File

@ -1,15 +1,17 @@
{
"name": "node-red-node-email",
"version": "1.18.5",
"version": "1.19.0",
"description": "Node-RED nodes to send and receive simple emails.",
"dependencies": {
"imap": "^0.8.19",
"node-pop3": "^0.8.0",
"mailparser": "~3.5.0",
"nodemailer": "^6.9.1",
"smtp-server": "^3.11.0"
},
"bundledDependencies": [
"imap",
"node-pop3",
"mailparser",
"nodemailer",
"smtp-server"

View File

@ -1,859 +0,0 @@
/*
Node.js POP3 client library
Copyright (C) 2011-2013 by Ditesh Shashikant Gathani <ditesh@gathani.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
var net = require("net"),
tls = require("tls"),
util = require("util"),
crypto = require("crypto"),
events = require("events");
// Constructor
function POP3Client(port, host, options) {
if (options === undefined) { options = {}; }
// Optional constructor arguments
var enabletls = options.enabletls !== undefined ? options.enabletls: false;
var ignoretlserrs = options.ignoretlserrs !== undefined ? options.ignoretlserrs: false;
var debug = options.debug || false;
var tlsDirectOpts = options.tlsopts !== undefined ? options.tlsopts: {};
// Private variables follow
var self = this;
var response = null;
var checkResp = true;
var bufferedData = "";
var state = 0;
var locked = false;
var multiline = false;
var socket = null;
var tlssock = null;
var callback = function(resp, data) {
if (resp === false) {
locked = false;
callback = function() {};
self.emit("connect", false, data);
} else {
// Checking for APOP support
var banner = data.trim();
var bannerComponents = banner.split(" ");
for(var i=0; i < bannerComponents.length; i++) {
if (bannerComponents[i].indexOf("@") > 0) {
self.data["apop"] = true;
self.data["apop-timestamp"] = bannerComponents[i];
break;
}
}
state = 1;
self.data["banner"] = banner;
self.emit("connect", true, data);
}
};
// Public variables follow
this.data = {
host: host,
port: port,
banner: "",
stls: false,
apop: false,
username: "",
tls: enabletls,
ignoretlserrs: ignoretlserrs
};
// Privileged methods follow
this.setCallback = function(cb) { callback = cb; };
this.getCallback = function() { return callback };
this.setState = function(val) { state = val; };
this.getState = function() { return state; };
this.setLocked = function(val) { locked = val; };
this.getLocked = function() { return locked; };
this.setMultiline = function(val) { multiline = val; };
this.getMultiline = function() { return multiline; };
// Writes to remote server socket
this.write = function(command, argument) {
var text = command;
if (argument !== undefined) { text = text + " " + argument + "\r\n"; }
else { text = text + "\r\n"; }
if (debug) { console.log("Client: " + util.inspect(text)); }
socket.write(text);
};
// Kills the socket connection
this.end = function() {
socket.end();
};
// Upgrades a standard unencrypted TCP connection to use TLS
// Liberally copied and modified from https://gist.github.com/848444
// starttls() should be a private function, but I can't figure out
// how to get a public prototypal method (stls) to talk to private method (starttls)
// which references private variables without going through a privileged method
this.starttls = function(options) {
var s = socket;
s.removeAllListeners("end");
s.removeAllListeners("data");
s.removeAllListeners("error");
socket=null;
var sslcontext = require('crypto').createCredentials(options);
var pair = tls.createSecurePair(sslcontext, false);
var cleartext = pipe(pair);
pair.on('secure', function() {
var verifyError = pair.ssl.verifyError();
cleartext.authorized = true;
if (verifyError) {
cleartext.authorized = false;
cleartext.authorizationError = verifyError;
}
cleartext.on("data", onData);
cleartext.on("error", onError);
cleartext.on("end", onEnd);
socket=cleartext;
(self.getCallback())(cleartext.authorized, cleartext.authorizationError);
});
cleartext._controlReleased = true;
function pipe(pair) {
pair.encrypted.pipe(s);
s.pipe(pair.encrypted);
pair.fd = s.fd;
var cleartext = pair.cleartext;
cleartext.socket = s;
cleartext.encrypted = pair.encrypted;
cleartext.authorized = false;
function onerror(e) {
if (cleartext._controlReleased) { cleartext.emit('error', e); }
}
function onclose() {
s.removeListener('error', onerror);
s.removeListener('close', onclose);
}
s.on('error', onerror);
s.on('close', onclose);
return cleartext;
}
};
// Private methods follow
// Event handlers follow
function onData(data) {
data = data.toString("ascii");
bufferedData += data;
if (debug) { console.log("Server: " + util.inspect(data)); }
if (checkResp === true) {
if (bufferedData.substr(0, 3) === "+OK") {
checkResp = false;
response = true;
} else if (bufferedData.substr(0, 4) === "-ERR") {
checkResp = false;
response = false;
// The following is only used for SASL
} else if (multiline === false) {
checkResp = false;
response = true;
}
}
if (checkResp === false) {
if (multiline === true && (response === false || bufferedData.substr(bufferedData.length-5) === "\r\n.\r\n")) {
// Make a copy to avoid race conditions
var responseCopy = response;
var bufferedDataCopy = bufferedData;
response = null;
checkResp = true;
multiline = false;
bufferedData = "";
callback(responseCopy, bufferedDataCopy);
} else if (multiline === false) {
// Make a copy to avoid race conditions
var responseCopy = response;
var bufferedDataCopy = bufferedData;
response = null;
checkResp = true;
multiline = false;
bufferedData = "";
callback(responseCopy, bufferedDataCopy);
}
}
}
function onError(err) {
if (err.errno === 111) { self.emit("connect", false, err); }
else { self.emit("error", err); }
}
function onEnd(data) {
self.setState(0);
socket = null;
}
function onClose() {
self.emit("close");
}
// Constructor code follows
// Set up EventEmitter constructor function
events.EventEmitter.call(this);
// Remote end socket
if (enabletls === true) {
tlssock = tls.connect({
host: host,
port: port,
rejectUnauthorized: !self.data.ignoretlserrs
}, function() {
if (tlssock.authorized === false && self.data["ignoretlserrs"] === false) {
self.emit("tls-error", tlssock.authorizationError);
}
}
);
socket = tlssock;
} else { socket = new net.createConnection(port, host); }
// Set up event handlers
socket.on("data", onData);
socket.on("error", onError);
socket.on("end", onEnd);
socket.on("close", onClose);
}
util.inherits(POP3Client, events.EventEmitter);
POP3Client.prototype.login = function (username, password) {
var self = this;
if (self.getState() !== 1) { self.emit("invalid-state", "login"); }
else if (self.getLocked() === true) { self.emit("locked", "login"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
if (resp === false) {
self.setLocked(false);
self.setCallback(function() {});
self.emit("login", false, data);
} else {
self.setCallback(function(resp, data) {
self.setLocked(false);
self.setCallback(function() {});
if (resp !== false) { self.setState(2); }
self.emit("login", resp, data);
});
self.setMultiline(false);
self.write("PASS", password);
}
});
self.setMultiline(false);
self.write("USER", username);
}
};
// SASL AUTH implementation
// Currently supports SASL PLAIN and CRAM-MD5
POP3Client.prototype.auth = function (type, username, password) {
type = type.toUpperCase();
var self = this;
var types = {"PLAIN": 1, "CRAM-MD5": 1};
var initialresp = "";
if (self.getState() !== 1) { self.emit("invalid-state", "auth"); }
else if (self.getLocked() === true) { self.emit("locked", "auth"); }
if ((type in types) === false) {
self.emit("auth", false, "Invalid auth type", null);
return;
}
function tlsok() {
if (type === "PLAIN") {
initialresp = " " + new Buffer(username + "\u0000" + username + "\u0000" + password).toString("base64") + "=";
self.setCallback(function(resp, data) {
if (resp !== false) { self.setState(2); }
self.emit("auth", resp, data, data);
});
} else if (type === "CRAM-MD5") {
self.setCallback(function(resp, data) {
if (resp === false) { self.emit("auth", resp, "Server responded -ERR to AUTH CRAM-MD5", data); }
else {
var challenge = new Buffer(data.trim().substr(2), "base64").toString();
var hmac = crypto.createHmac("md5", password);
var response = new Buffer(username + " " + hmac.update(challenge).digest("hex")).toString("base64");
self.setCallback(function(resp, data) {
var errmsg = null;
if (resp !== false) { self.setState(2); }
else {errmsg = "Server responded -ERR to response"; }
self.emit("auth", resp, null, data);
});
self.write(response);
}
});
}
self.write("AUTH " + type + initialresp);
}
if (self.data["tls"] === false && self.data["stls"] === false) {
// Remove all existing STLS listeners
self.removeAllListeners("stls");
self.on("stls", function(resp, rawdata) {
if (resp === false) {
// We (optionally) ignore self signed cert errors,
// in blatant violation of RFC 2595, Section 2.4
if (self.data["ignoretlserrs"] === true && rawdata === "DEPTH_ZERO_SELF_SIGNED_CERT"){ tlsok(); }
else { self.emit("auth", false, "Unable to upgrade connection to STLS", rawdata); }
} else { tlsok(); }
});
self.stls();
} else { tlsok(); }
};
POP3Client.prototype.apop = function (username, password) {
var self = this;
if (self.getState() !== 1) { self.emit("invalid-state", "apop"); }
else if (self.getLocked() === true) { self.emit("locked", "apop"); }
else if (self.data["apop"] === false) { self.emit("apop", false, "APOP support not detected on remote server"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
self.setLocked(false);
self.setCallback(function() {});
if (resp === true) { self.setState(2); }
self.emit("apop", resp, data);
});
self.setMultiline(false);
self.write("APOP", username + " " + crypto.createHash("md5").update(self.data["apop-timestamp"] + password).digest("hex"));
}
};
POP3Client.prototype.stls = function() {
var self = this;
if (self.getState() !== 1) { self.emit("invalid-state", "stls"); }
else if (self.getLocked() === true) { self.emit("locked", "stls"); }
else if (self.data["tls"] === true) { self.emit("stls", false, "Unable to execute STLS as TLS connection already established"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
self.setLocked(false);
self.setCallback(function() {});
if (resp === true) {
self.setCallback(function(resp, data) {
if (resp === false && self.data["ignoretlserrs"] === true && data === "DEPTH_ZERO_SELF_SIGNED_CERT") {resp = true; }
self.data["stls"] = true;
self.emit("stls", resp, data);
});
self.starttls();
} else { self.emit("stls", false, data); }
});
self.setMultiline(false);
self.write("STLS");
}
};
POP3Client.prototype.top = function(msgnumber, lines) {
var self = this;
if (self.getState() !== 2) { self.emit("invalid-state", "top"); }
else if (self.getLocked() === true) { self.emit("locked", "top"); }
else {
self.setCallback(function(resp, data) {
var returnValue = null;
self.setLocked(false);
self.setCallback(function() {});
if (resp !== false) {
returnValue = "";
var startOffset = data.indexOf("\r\n", 0) + 2;
var endOffset = data.indexOf("\r\n.\r\n", 0) + 2;
if (endOffset > startOffset) {returnValue = data.substr(startOffset, endOffset-startOffset); }
}
self.emit("top", resp, msgnumber, returnValue, data);
});
self.setMultiline(true);
self.write("TOP", msgnumber + " " + lines);
}
};
POP3Client.prototype.list = function(msgnumber) {
var self = this;
if (self.getState() !== 2) { self.emit("invalid-state", "list"); }
else if (self.getLocked() === true) { self.emit("locked", "list"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
var returnValue = null;
var msgcount = 0;
self.setLocked(false);
self.setCallback(function() {});
if (resp !== false) {
returnValue = [];
var listitem = "";
if (msgnumber !== undefined) {
msgcount = 1
listitem = data.split(" ");
returnValue[listitem[1]] = listitem[2];
} else {
var offset = 0;
var newoffset = 0;
var returnValue = [];
var startOffset = data.indexOf("\r\n", 0) + 2;
var endOffset = data.indexOf("\r\n.\r\n", 0) + 2;
if (endOffset > startOffset) {
data = data.substr(startOffset, endOffset-startOffset);
while(true) {
if (offset > endOffset) { break; }
newoffset = data.indexOf("\r\n", offset);
if (newoffset < 0) { break; }
msgcount++;
listitem = data.substr(offset, newoffset-offset);
listitem = listitem.split(" ");
returnValue[listitem[0]] = listitem[1];
offset = newoffset + 2;
}
}
}
}
self.emit("list", resp, msgcount, msgnumber, returnValue, data);
});
if (msgnumber !== undefined) { self.setMultiline(false); }
else { self.setMultiline(true); }
self.write("LIST", msgnumber);
}
};
POP3Client.prototype.stat = function() {
var self = this;
if (self.getState() !== 2) { self.emit("invalid-state", "stat"); }
else if (self.getLocked() === true) { self.emit("locked", "stat"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
var returnValue = null;
self.setLocked(false);
self.setCallback(function() {});
if (resp !== false) {
var listitem = data.split(" ");
returnValue = {
"count": listitem[1].trim(),
"octets": listitem[2].trim(),
};
}
self.emit("stat", resp, returnValue, data);
});
self.setMultiline(false);
self.write("STAT", undefined);
}
};
POP3Client.prototype.uidl = function(msgnumber) {
var self = this;
if (self.getState() !== 2) { self.emit("invalid-state", "uidl"); }
else if (self.getLocked() === true) { self.emit("locked", "uidl"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
var returnValue = null;
self.setLocked(false);
self.setCallback(function() {});
if (resp !== false) {
returnValue = [];
var listitem = "";
if (msgnumber !== undefined) {
listitem = data.split(" ");
returnValue[listitem[1]] = listitem[2].trim();
} else {
var offset = 0;
var newoffset = 0;
var returnValue = [];
var startOffset = data.indexOf("\r\n", 0) + 2;
var endOffset = data.indexOf("\r\n.\r\n", 0) + 2;
if (endOffset > startOffset) {
data = data.substr(startOffset, endOffset-startOffset);
endOffset -= startOffset;
while (offset < endOffset) {
newoffset = data.indexOf("\r\n", offset);
listitem = data.substr(offset, newoffset-offset);
listitem = listitem.split(" ");
returnValue[listitem[0]] = listitem[1];
offset = newoffset + 2;
}
}
}
}
self.emit("uidl", resp, msgnumber, returnValue, data);
});
if (msgnumber !== undefined) { self.setMultiline(false); }
else { self.setMultiline(true); }
self.write("UIDL", msgnumber);
}
};
POP3Client.prototype.retr = function(msgnumber) {
var self = this;
if (self.getState() !== 2) { self.emit("invalid-state", "retr"); }
else if (self.getLocked() === true) { self.emit("locked", "retr"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
var returnValue = null;
self.setLocked(false);
self.setCallback(function() {});
if (resp !== false) {
var startOffset = data.indexOf("\r\n", 0) + 2;
var endOffset = data.indexOf("\r\n.\r\n", 0);
returnValue = data.substr(startOffset, endOffset-startOffset);
}
self.emit("retr", resp, msgnumber, returnValue, data);
});
self.setMultiline(true);
self.write("RETR", msgnumber);
}
};
POP3Client.prototype.dele = function(msgnumber) {
var self = this;
if (self.getState() !== 2) { self.emit("invalid-state", "dele"); }
else if (self.getLocked() === true) { self.emit("locked", "dele"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
self.setLocked(false);
self.setCallback(function() {});
self.emit("dele", resp, msgnumber, data);
});
self.setMultiline(false);
self.write("DELE", msgnumber);
}
};
POP3Client.prototype.noop = function() {
var self = this;
if (self.getState() !== 2) { self.emit("invalid-state", "noop"); }
else if (self.getLocked() === true) { self.emit("locked", "noop"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
self.setLocked(false);
self.setCallback(function() {});
self.emit("noop", resp, data);
});
self.setMultiline(false);
self.write("NOOP", undefined);
}
};
POP3Client.prototype.rset = function() {
var self = this;
if (self.getState() !== 2) { self.emit("invalid-state", "rset"); }
else if (self.getLocked() === true) { self.emit("locked", "rset"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
self.setLocked(false);
self.setCallback(function() {});
self.emit("rset", resp, data);
});
self.setMultiline(false);
self.write("RSET", undefined);
}
};
POP3Client.prototype.capa = function() {
var self = this;
if (self.getState() === 0) { self.emit("invalid-state", "quit"); }
else if (self.getLocked() === true) { self.emit("locked", "capa"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
var returnValue = null;
self.setLocked(false);
self.setCallback(function() {});
if (resp === true) {
var startOffset = data.indexOf("\r\n", 0) + 2;
var endOffset = data.indexOf("\r\n.\r\n", 0);
returnValue = data.substr(startOffset, endOffset-startOffset);
returnValue = returnValue.split("\r\n");
}
self.emit("capa", resp, returnValue, data);
});
self.setMultiline(true);
self.write("CAPA", undefined);
}
};
POP3Client.prototype.quit = function() {
var self = this;
if (self.getState() === 0) { self.emit("invalid-state", "quit"); }
else if (self.getLocked() === true) { self.emit("locked", "quit"); }
else {
self.setLocked(true);
self.setCallback(function(resp, data) {
self.setLocked(false);
self.setCallback(function() {});
self.end();
self.emit("quit", resp, data);
});
self.setMultiline(false);
self.write("QUIT", undefined);
}
};
module.exports = POP3Client;

View File

@ -31,6 +31,25 @@ describe('email Node', function () {
n1.should.have.property("repeat", 300000);
n1.should.have.property("inserver", "imap.gmail.com");
n1.should.have.property("inport", "993");
n1.should.have.property("authtype", "BASIC");
done();
});
});
it('should force input on XOAuth2', function (done) {
var flow = [{
id: "n1",
type: "e-mail in",
name: "emailin",
authtype: "XOAUTH2",
wires: [
[]
]
}];
helper.load(emailNode, flow, function () {
var n1 = helper.getNode("n1");
n1.should.have.property("repeat", 0);
n1.should.have.property("inputs", 1);
done();
});
});
@ -51,6 +70,7 @@ describe('email Node', function () {
helper.load(emailNode, flow, function () {
var n1 = helper.getNode("n1");
n1.should.have.property('name', "emailout");
n1.should.have.property("authtype", "BASIC");
done();
});
});
@ -83,7 +103,7 @@ describe('email Node', function () {
//console.log(helper.log());
//logEvents.should.have.length(3);
logEvents[0][0].should.have.a.property('msg');
logEvents[0][0].msg.toString().should.startWith("email.errors.nopayload");
logEvents[2][0].msg.toString().should.startWith("email.errors.nopayload");
done();
} catch (e) {
done(e);
@ -134,7 +154,7 @@ describe('email Node', function () {
// console.log(logEvents[0][0].msg.toString());
//logEvents.should.have.length(3);
logEvents[0][0].should.have.a.property('msg');
logEvents[0][0].msg.toString().should.startWith("Error:");
logEvents[2][0].msg.toString().should.startWith("Error:");
done();
} catch (e) {
done(e);
@ -179,7 +199,7 @@ describe('email Node', function () {
//console.log(helper.log().args);
//logEvents.should.have.length(3);
logEvents[0][0].should.have.a.property('msg');
logEvents[0][0].msg.toString().should.startWith("Error:");
logEvents[2][0].msg.toString().should.startWith("Error:");
done();
} catch (e) {
done(e);