Verified Commit 8525fec3 authored by CompileNix's avatar CompileNix

remove deps "slack-node", "node-tail", reformat code and update deps

parent ad8b4a17
/node_modules
*.txt
*.swp
/Config.js
/config.js
......@@ -8,7 +8,7 @@
"type": "node",
"request": "launch",
"name": "Launch Program",
"program": "${workspaceFolder}/Index.js"
"program": "${workspaceFolder}/index.js"
}
]
}
\ No newline at end of file
class Config {
constructor() {
this.filesToWatch = [
"test.txt",
"test1.txt"
];
// nginx
this.defaultMessageTemplateFilter = /^\[((\d{1,2}\/\w{3}\/\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4}))\] status:(\d{3}) request_time:(\d{1,}\.\d{3}) upstream_response_time:((\d{1,}\.\d{3})|-) bytes_sent:(\d{1,}) client_ip:(\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|(:{0,2}[\da-f]{1,4}){1,8}) domain:([\w.-]*) port:(\d{1,5}) request:"((\w{3,8}) ([\w-.,_~:<>\\\/\[\]%@!$'()*+;?=&#]*) HTTP\/(\d\.\d))" referer:"([\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*)" user_agent:"([\w-.,_~: /[\]%@!$'()*+;?=&#]*)"$/;
this.defaultMatchingGroupName = {
All: 0,
DateTime: 1,
Date: 2,
Time: 3,
TimeZone: 4,
StatusCode: 5,
RequestTime: 6,
UpstreamResponseTime: 7,
BytesSent: 9,
ClientIp: 10,
Domain: 12,
Port: 13,
Request: 14,
Method: 15,
Path: 16,
ProtocolVersion: 17,
Referer: 18,
UserAgent: 19
};
// apache
// this.defaultMessageTemplateFilter = /^([\w.-]*):(\d{1,5}) (\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|(:{0,2}[\da-f]{1,4}){1,8}) ([\w.-]*) ([\w.-]*) \[((\d{1,2}\/\w{3}\/\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4}))\] "((\w{3,8}) (\/[\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*) HTTP\/(\d\.\d))" (\d{3}) (\d{1,}) "([\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*)" "([\w-.,_~: \/[\]%@!$'()*+;?=&#]*)"$/;
// this.defaultMatchingGroupName = {
// All: 0,
// Domain: 1,
// Port: 2,
// ClientIp: 3,
// RemoteLogonName: 5,
// RemoteUser: 6,
// DateTime: 7,
// Date: 8,
// Time: 9,
// TimeZone: 10,
// Request: 11,
// Method: 12,
// Path: 13,
// ProtocolVersion: 14,
// StatusCode: 15,
// BytesSent: 16,
// Referer: 17,
// UserAgent: 18
// };
this.enableEmail = false;
this.smtpUsername = "smtp login username";
this.smtpPassword = "password";
this.smtpHost = "your.email.server";
this.smtpPort = 25;
this.smtps = false;
this.mailfrom = "foo@bar.local";
this.mailto = "foo@bar.local";
this.subjectPrefix = "access_log mailer: ";
this.enableSlack = false;
this.slackWebHookUri = "https://hooks.slack.com/services/xxxxxx/xxxxxx/xxxxxx";
this.slackChannel = "#general";
this.slackUsername = "webserver-access_log-bot";
this.botName = "web-access_log2email";
this.botIcon = "https://compilenix.org/cdn/Compilenix.png";
this.debug = true;
this.expressions = [
{
match: / status:503 /,
subject: (/** @type {string[]|string} */ match) => {
return `HTTP ${match[this.defaultMatchingGroupName.StatusCode]} (something we're already fixing...)\n`;
},
template: (/** @type {string[]|string} */ match) => {
return `${match[this.defaultMatchingGroupName.Method]} ${match[this.defaultMatchingGroupName.Domain]} \`${match[this.defaultMatchingGroupName.Path]}\`\nUser-Agent: \`${match[this.defaultMatchingGroupName.UserAgent]}\``;
},
slackOptions: {
channel: this.slackChannel,
username: this.slackUsername,
attachments: [{
footer: this.botName,
footer_icon: this.botIcon,
color: "#f0d32c",
mrkdwn_in: ["text", "pretext"]
}]
}
},
{
match: / status:5(?!03)\d{2} /, // matches all 5xx status codes, except a 503 (using regex negative lookahead)
//filter: (/** @type {string} */ line) => { return line.match(/./); }, // use custom regex filter instead of this.defaultMatchingGroupName
subject: (/** @type {string[]|string} */ match) => {
if (match[this.defaultMatchingGroupName.Path].startsWith("/RequestToIgnore")) return false; // return false to ignore/dismiss this message
return `HTTP ${match[this.defaultMatchingGroupName.StatusCode]}\n`;
},
template: (/** @type {string[]|string} */ match) => {
return `${match[this.defaultMatchingGroupName.Method]} ${match[this.defaultMatchingGroupName.Domain]} \`${match[this.defaultMatchingGroupName.Path]}\`\nUser-Agent: \`${match[this.defaultMatchingGroupName.UserAgent]}\``;
},
slackOptions: {
channel: this.slackChannel,
username: this.slackUsername,
attachments: [{
footer: this.botName,
footer_icon: this.botIcon,
color: "#c4463d",
mrkdwn_in: ["text", "pretext"]
}]
}
}
];
}
}
module.exports = new Config();
"use-strict";
const fs = require("fs-extra");
const nodemailer = require("nodemailer");
const config = require("./Config.js");
const Tail = require("tail").Tail;
const Slack = require("slack-node");
let transporter;
let slack = new Slack();
let mailOptions = {};
const fileWatchers = {};
let messages = new Array();
let queueWorkerRunning = false;
async function notificationQueueWorker() {
if (queueWorkerRunning) return;
queueWorkerRunning = true;
while (messages.length > 0) {
let message = messages[0];
messages.shift();
if (!message) {
continue;
}
let filterExpression = config.defaultMessageTemplateFilter;
if (message.expression.filter) {
filterExpression = message.expression.filter;
}
let match = message.message.match(filterExpression);
if (match === null) match = message.message;
const oldSubject = message.expression.subject;
if (typeof (message.expression.subject) !== "function") {
var oldSubjectValue = "";
if (message.expression.subject && message.expression.subject.toString) oldSubjectValue = message.expression.subject.toString();
message.expression.subject = () => oldSubjectValue;
}
const oldTemplate = message.expression.template;
if (typeof (message.expression.template) !== "function") {
var oldTemplateValue = `\`${message.message}\``;
if (message.expression.template && message.expression.template.toString) oldTemplateValue = message.expression.template.toString();
message.expression.template = () => oldTemplateValue;
}
let subject = message.expression.subject(match);
let template = message.expression.template(match);
let messageFiltered = false;
if (subject === false || template === false) {
messageFiltered = true;
}
if (!messageFiltered && config.enableEmail) {
await sendMail({
from: config.mailfrom,
to: config.mailto,
subject: `${config.subjectPrefix}${subject}`,
text: template
});
}
const oldSlackOptions = message.expression.slackOptions;
if (!messageFiltered && config.enableSlack) {
let payload = message.expression.slackOptions;
payload.attachments[0].fallback = `${subject}${template}`;
payload.attachments[0].text = payload.attachments[0].fallback;
payload.attachments[0].ts = Date.now() / 1000;
slack.webhook(payload, (err, response) => {
if (config.debug && err) console.log(err, response);
});
}
message.expression.subject = oldSubject;
message.expression.template = oldTemplate;
message.expression.slackOptions = oldSlackOptions;
if (!messageFiltered) {
if (messages.length > 0) console.log(`remaining messages in queue: ${messages.length}`);
await sleep(1000);
}
}
queueWorkerRunning = false;
}
async function filterLog( /** @type {string} */ line) {
for (let index = 0; index < config.expressions.length; index++) {
const expression = config.expressions[index];
expression.match.lastIndex = 0;
if (expression.match.test(line)) {
messages.push({
expression: expression,
message: line
});
}
}
}
async function sendMail(mailOptions) {
return new Promise((resolve, reject) => {
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
if (config.debug) console.log(error);
reject(error);
} else {
if (config.debug) console.log("Email sent: " + info.response);
resolve(info);
}
});
});
}
async function setupTail( /** @type {string[]} */ filesToWatch) {
if (!config.expressions || config.expressions.length < 1) {
console.error("no expressions defined in config!");
process.exit(1);
}
for (const fileName of filesToWatch) {
if (!(await fs.exists(fileName))) {
console.log(`File not found, not watching: ${fileName}`);
continue;
}
const tail = new Tail(fileName, {
separator: /[\r]{0,1}\n/,
fromBeginning: false,
fsWatchOptions: {},
follow: true
});
tail.on("line", ( /** @type {string} */ data) => {
filterLog(data);
});
tail.on("error", error => {
console.log(`Watcher ERROR (${fileName}): `, error);
});
fileWatchers[fileName] = tail;
}
}
function setupSmtp() {
transporter = nodemailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
secure: config.smtps,
auth: {
user: config.smtpUsername,
pass: config.smtpPassword
}
});
mailOptions = {
from: config.mailfrom,
to: config.mailto,
subject: `${config.subjectPrefix} -`,
text: ""
};
}
function setupSlack() {
slack.setWebhook(config.slackWebHookUri);
}
function sleep(/** @type {Number} */ ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
(async() => {
setupSmtp();
setupSlack();
await setupTail(config.filesToWatch);
setInterval(notificationQueueWorker, 1000);
})();
MIT License
Copyright (c) 2017 Compilenix
Copyright (c) 2019 Compilenix
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
......
// @ts-nocheck
/* eslint-disable no-undef */
// Nginx
// log_format main '[$time_local] status:$status request_time:$request_time upstream_response_time:$upstream_response_time bytes_sent:$body_bytes_sent client_ip:$remote_addr domain:$host port:$server_port request:"$request" referer:"$http_referer" user_agent:"$http_user_agent"';
defaultMessageTemplateFilter = /^\[((\d{1,2}\/\w{3}\/\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4}))\] status:(\d{3}) request_time:(\d{1,}\.\d{3}) upstream_response_time:((\d{1,}\.\d{3})|-) bytes_sent:(\d{1,}) client_ip:(\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|(:{0,2}[\da-f]{1,4}){1,8}) domain:([\w.-]*) port:(\d{1,5}) request:"((\w{3,8}) ([\w-.,_~:<>\\\/\[\]%@!$'()*+;?=&#]*) HTTP\/(\d\.\d))" referer:"([\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*)" user_agent:"([\w-.,_~: /[\]%@!$'()*+;?=&#]*)"$/;
defaultMessageTemplateFilter = /^\[((\d{1,2}\/\w{3}\/\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4}))\] status:(\d{3}) request_time:(\d{1,}\.\d{3}) upstream_response_time:((\d{1,}\.\d{3})|-) bytes_sent:(\d{1,}) client_ip:(\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|(:{0,2}[\da-f]{1,4}){1,8}) domain:([\w.-]*) port:(\d{1,5}) request:"((\w{3,8}) ([\w-.,_~:<>\\\/\[\]%@!$'()*+;?=&#]*) HTTP\/(\d\.\d))" referer:"([\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*)" user_agent:"([\w-.,_~: /[\]%@!$'()*+;?=&#]*)"$/
defaultMatchingGroupName = {
All: 0,
DateTime: 1,
Date: 2,
Time: 3,
TimeZone: 4,
StatusCode: 5,
RequestTime: 6,
UpstreamResponseTime: 7,
BytesSent: 9,
ClientIp: 10,
Domain: 12,
Port: 13,
Request: 14,
Method: 15,
Path: 16,
ProtocolVersion: 17,
Referer: 18,
UserAgent: 19
};
All: 0,
DateTime: 1,
Date: 2,
Time: 3,
TimeZone: 4,
StatusCode: 5,
RequestTime: 6,
UpstreamResponseTime: 7,
BytesSent: 9,
ClientIp: 10,
Domain: 12,
Port: 13,
Request: 14,
Method: 15,
Path: 16,
ProtocolVersion: 17,
Referer: 18,
UserAgent: 19
}
// Test strings
// [10/Dec/2017:01:08:21 +0100] status:200 request_time:60.004 upstream_response_time:60.000 bytes_sent:228 client_ip:1.22.33.44 domain:example.com request:"GET / HTTP/2.0" referer:"-" user_agent:""
......@@ -50,27 +52,27 @@ defaultMatchingGroupName = {
// ----------------------------------------------------
// Apache
// LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined
defaultMessageTemplateFilter = /^([\w.-]*):(\d{1,5}) (\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|(:{0,2}[\da-f]{1,4}){1,8}) ([\w.-]*) ([\w.-]*) \[((\d{1,2}\/\w{3}\/\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4}))\] "((\w{3,8}) (\/[\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*) HTTP\/(\d\.\d)|-)" (\d{3}) (\d{1,}) "([\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*)" "([\w-.,_~: \/[\]%@!$'()*+;?=&#]*)"$/;
defaultMessageTemplateFilter = /^([\w.-]*):(\d{1,5}) (\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|(:{0,2}[\da-f]{1,4}){1,8}) ([\w.-]*) ([\w.-]*) \[((\d{1,2}\/\w{3}\/\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4}))\] "((\w{3,8}) (\/[\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*) HTTP\/(\d\.\d)|-)" (\d{3}) (\d{1,}) "([\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*)" "([\w-.,_~: /[\]%@!$'()*+;?=&#]*)"$/
defaultMatchingGroupName = {
All: 0,
Domain: 1,
Port: 2,
ClientIp: 3,
RemoteLogonName: 5,
RemoteUser: 6,
DateTime: 7,
Date: 8,
Time: 9,
TimeZone: 10,
Request: 11,
Method: 12,
Path: 13,
ProtocolVersion: 14,
StatusCode: 15,
BytesSent: 16,
Referer: 17,
UserAgent: 18
};
All: 0,
Domain: 1,
Port: 2,
ClientIp: 3,
RemoteLogonName: 5,
RemoteUser: 6,
DateTime: 7,
Date: 8,
Time: 9,
TimeZone: 10,
Request: 11,
Method: 12,
Path: 13,
ProtocolVersion: 14,
StatusCode: 15,
BytesSent: 16,
Referer: 17,
UserAgent: 18
}
// Test strings
// example.com:443 1.22.33.44 - - [11/Dec/2017:15:48:27 +0100] "GET / HTTP/2.0" 200 228 "-" ""
......
......@@ -5,10 +5,10 @@ https://www.paypal.me/compilenix
```sh
git clone https://git.compilenix.org/Compilenix/web-access_log2email.git
cd web-access_log2email
npm install
cp Config.example.js Config.js
$EDITOR Config.js # make your changes
node Index.js
npm ci --production
cp config.example.js config.js
$EDITOR config.js # make your changes
node index.js
```
## Profit!
class Config {
constructor () {
this.filesToWatch = [
'test.txt',
'test1.txt'
]
// nginx
this.defaultMessageTemplateFilter = /^\[((\d{1,2}\/\w{3}\/\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4}))\] status:(\d{3}) request_time:(\d{1,}\.\d{3}) upstream_response_time:((\d{1,}\.\d{3})|-) bytes_sent:(\d{1,}) client_ip:(\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|(:{0,2}[\da-f]{1,4}){1,8}) domain:([\w.-]*) port:(\d{1,5}) request:"((\w{3,8}) ([\w-.,_~:<>\\\/\[\]%@!$'()*+;?=&#]*) HTTP\/(\d\.\d))" referer:"([\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*)" user_agent:"([\w-.,_~: /[\]%@!$'()*+;?=&#]*)"$/
this.defaultMatchingGroupName = {
All: 0,
DateTime: 1,
Date: 2,
Time: 3,
TimeZone: 4,
StatusCode: 5,
RequestTime: 6,
UpstreamResponseTime: 7,
BytesSent: 9,
ClientIp: 10,
Domain: 12,
Port: 13,
Request: 14,
Method: 15,
Path: 16,
ProtocolVersion: 17,
Referer: 18,
UserAgent: 19
}
// apache
// this.defaultMessageTemplateFilter = /^([\w.-]*):(\d{1,5}) (\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|(:{0,2}[\da-f]{1,4}){1,8}) ([\w.-]*) ([\w.-]*) \[((\d{1,2}\/\w{3}\/\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4}))\] "((\w{3,8}) (\/[\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*) HTTP\/(\d\.\d))" (\d{3}) (\d{1,}) "([\w-.,_~:<>\\/[\]%@!$'()*+;?=&#]*)" "([\w-.,_~: \/[\]%@!$'()*+;?=&#]*)"$/;
// this.defaultMatchingGroupName = {
// All: 0,
// Domain: 1,
// Port: 2,
// ClientIp: 3,
// RemoteLogonName: 5,
// RemoteUser: 6,
// DateTime: 7,
// Date: 8,
// Time: 9,
// TimeZone: 10,
// Request: 11,
// Method: 12,
// Path: 13,
// ProtocolVersion: 14,
// StatusCode: 15,
// BytesSent: 16,
// Referer: 17,
// UserAgent: 18
// };
this.enableEmail = false
this.smtpUsername = 'smtp login username'
this.smtpPassword = 'password'
this.smtpHost = 'your.email.server'
this.smtpPort = 25
this.smtps = false
this.mailfrom = 'foo@bar.local'
this.mailto = 'foo@bar.local'
this.subjectPrefix = 'access_log mailer: '
this.enableSlack = false
this.slackWebHookUri = 'https://hooks.slack.com/services/xxxxxx/xxxxxx/xxxxxx'
this.rejectUnauthorized = false
this.slackChannel = '#general'
this.slackUsername = 'webserver-access_log-bot'
this.botName = 'web-access_log2email'
this.botIcon = 'https://compilenix.org/cdn/Compilenix.png'
this.debug = true
this.expressions = [
{
match: / status:503 /,
subject: (/** @type {string[]|string} */ match) => {
return `HTTP ${match[this.defaultMatchingGroupName.StatusCode]} (something we're already fixing...)\n`
},
template: (/** @type {string[]|string} */ match) => {
return `${match[this.defaultMatchingGroupName.Method]} ${match[this.defaultMatchingGroupName.Domain]} \`${match[this.defaultMatchingGroupName.Path]}\`\nUser-Agent: \`${match[this.defaultMatchingGroupName.UserAgent]}\``
},
webhookUri: 'https://hooks.slack.com/services/xxxxxx/xxxxxx/xxxxxx',
slackOptions: {
channel: this.slackChannel,
username: this.slackUsername,
attachments: [{
footer: this.botName,
footer_icon: this.botIcon,
color: '#f0d32c',
mrkdwn_in: ['text', 'pretext']
}]
}
},
{
match: / status:5(?!03)\d{2} /, // matches all 5xx status codes, except a 503 (using regex negative lookahead)
// filter: (/** @type {string} */ line) => { return line.match(/./); }, // use custom regex filter instead of this.defaultMatchingGroupName
subject: (/** @type {string[]|string} */ match) => {
if (match[this.defaultMatchingGroupName.Path].startsWith('/RequestToIgnore')) return false // return false to ignore/dismiss this message
return `HTTP ${match[this.defaultMatchingGroupName.StatusCode]}\n`
},
template: (/** @type {string[]|string} */ match) => {
return `${match[this.defaultMatchingGroupName.Method]} ${match[this.defaultMatchingGroupName.Domain]} \`${match[this.defaultMatchingGroupName.Path]}\`\nUser-Agent: \`${match[this.defaultMatchingGroupName.UserAgent]}\``
},
webhookUri: 'https://hooks.slack.com/services/xxxxxx/xxxxxx/xxxxxx',
slackOptions: {
channel: this.slackChannel,
username: this.slackUsername,
attachments: [{
footer: this.botName,
footer_icon: this.botIcon,
color: '#c4463d',
mrkdwn_in: ['text', 'pretext']
}]
}
}
]
}
}
module.exports = new Config()
'use-strict'
const https = require('https')
const fs = require('fs-extra')
const nodemailer = require('nodemailer')
const Tail = require('tail').Tail
const config = require('./config.js')
let transporter
let mailOptions = {}
const fileWatchers = {}
let messages = []
let queueWorkerRunning = false
async function notificationQueueWorker () {
if (queueWorkerRunning) return
queueWorkerRunning = true
while (messages.length > 0) {
let message = messages[0]
messages.shift()
if (!message) {
continue
}
let filterExpression = config.defaultMessageTemplateFilter
if (message.expression.filter) {
filterExpression = message.expression.filter
}
let match = message.message.match(filterExpression)
if (match === null) match = message.message
const oldSubject = message.expression.subject
if (typeof (message.expression.subject) !== 'function') {
var oldSubjectValue = ''
if (message.expression.subject && message.expression.subject.toString) oldSubjectValue = message.expression.subject.toString()
message.expression.subject = () => oldSubjectValue
}
const oldTemplate = message.expression.template
if (typeof (message.expression.template) !== 'function') {
var oldTemplateValue = `\`${message.message}\``
if (message.expression.template && message.expression.template.toString) oldTemplateValue = message.expression.template.toString()
message.expression.template = () => oldTemplateValue
}
let subject = message.expression.subject(match)
let template = message.expression.template(match)
let messageFiltered = false
if (subject === false || template === false) {
messageFiltered = true
}
if (!messageFiltered && config.enableEmail) {
await sendMail({
from: config.mailfrom,
to: config.mailto,
subject: `${config.subjectPrefix}${subject}`,
text: template
})
}
const oldSlackOptions = message.expression.slackOptions
if (!messageFiltered && config.enableSlack) {
let payload = message.expression.slackOptions
payload.attachments[0].fallback = `${subject}${template}`
payload.attachments[0].text = payload.attachments[0].fallback
payload.attachments[0].ts = Date.now() / 1000
await sendWebook(payload, message.expression.webhookUri)
}
message.expression.subject = oldSubject
message.expression.template = oldTemplate
message.expression.slackOptions = oldSlackOptions
if (!messageFiltered) {
if (messages.length > 0) console.log(`remaining messages in queue: ${messages.length}`)
await sleep(1000)
}
}
queueWorkerRunning = false
}
async function filterLog (/** @type {string} */ line) {
for (let index = 0; index < config.expressions.length; index++) {
const expression = config.expressions[index]
expression.match.lastIndex = 0
if (expression.match.test(line)) {
messages.push({
expression: expression,
message: line
})
}
}
}
async function sendMail (mailOptions) {
return new Promise((resolve, reject) => {
transporter.sendMail(mailOptions, (error, info) => {
if (error) {
if (config.debug) console.log(error)
reject(error)
} else {
if (config.debug) console.log('Email sent: ' + info.response)
resolve(info)
}