diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 8653bab7..55476d8b 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,5 +1,9 @@ # Changelog +## 1.15.0 2016-07-28 + + * Check SMTP settings using AJAX instead of posting entire form + ## 1.14.0 2016-07-09 * Fixed ANY match segments with range queries diff --git a/package.json b/package.json index 2e07e120..6dbb02c5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mailtrain", "private": true, - "version": "1.14.0", + "version": "1.15.0", "description": "Self hosted email newsletter app", "main": "index.js", "scripts": { diff --git a/public/javascript/fetch.js b/public/javascript/fetch.js new file mode 100644 index 00000000..d0652dea --- /dev/null +++ b/public/javascript/fetch.js @@ -0,0 +1,433 @@ +(function(self) { + 'use strict'; + + if (self.fetch) { + return + } + + var support = { + searchParams: 'URLSearchParams' in self, + iterable: 'Symbol' in self && 'iterator' in Symbol, + blob: 'FileReader' in self && 'Blob' in self && (function() { + try { + new Blob() + return true + } catch(e) { + return false + } + })(), + formData: 'FormData' in self, + arrayBuffer: 'ArrayBuffer' in self + } + + function normalizeName(name) { + if (typeof name !== 'string') { + name = String(name) + } + if (/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(name)) { + throw new TypeError('Invalid character in header field name') + } + return name.toLowerCase() + } + + function normalizeValue(value) { + if (typeof value !== 'string') { + value = String(value) + } + return value + } + + // Build a destructive iterator for the value list + function iteratorFor(items) { + var iterator = { + next: function() { + var value = items.shift() + return {done: value === undefined, value: value} + } + } + + if (support.iterable) { + iterator[Symbol.iterator] = function() { + return iterator + } + } + + return iterator + } + + function Headers(headers) { + this.map = {} + + if (headers instanceof Headers) { + headers.forEach(function(value, name) { + this.append(name, value) + }, this) + + } else if (headers) { + Object.getOwnPropertyNames(headers).forEach(function(name) { + this.append(name, headers[name]) + }, this) + } + } + + Headers.prototype.append = function(name, value) { + name = normalizeName(name) + value = normalizeValue(value) + var list = this.map[name] + if (!list) { + list = [] + this.map[name] = list + } + list.push(value) + } + + Headers.prototype['delete'] = function(name) { + delete this.map[normalizeName(name)] + } + + Headers.prototype.get = function(name) { + var values = this.map[normalizeName(name)] + return values ? values[0] : null + } + + Headers.prototype.getAll = function(name) { + return this.map[normalizeName(name)] || [] + } + + Headers.prototype.has = function(name) { + return this.map.hasOwnProperty(normalizeName(name)) + } + + Headers.prototype.set = function(name, value) { + this.map[normalizeName(name)] = [normalizeValue(value)] + } + + Headers.prototype.forEach = function(callback, thisArg) { + Object.getOwnPropertyNames(this.map).forEach(function(name) { + this.map[name].forEach(function(value) { + callback.call(thisArg, value, name, this) + }, this) + }, this) + } + + Headers.prototype.keys = function() { + var items = [] + this.forEach(function(value, name) { items.push(name) }) + return iteratorFor(items) + } + + Headers.prototype.values = function() { + var items = [] + this.forEach(function(value) { items.push(value) }) + return iteratorFor(items) + } + + Headers.prototype.entries = function() { + var items = [] + this.forEach(function(value, name) { items.push([name, value]) }) + return iteratorFor(items) + } + + if (support.iterable) { + Headers.prototype[Symbol.iterator] = Headers.prototype.entries + } + + function consumed(body) { + if (body.bodyUsed) { + return Promise.reject(new TypeError('Already read')) + } + body.bodyUsed = true + } + + function fileReaderReady(reader) { + return new Promise(function(resolve, reject) { + reader.onload = function() { + resolve(reader.result) + } + reader.onerror = function() { + reject(reader.error) + } + }) + } + + function readBlobAsArrayBuffer(blob) { + var reader = new FileReader() + reader.readAsArrayBuffer(blob) + return fileReaderReady(reader) + } + + function readBlobAsText(blob) { + var reader = new FileReader() + reader.readAsText(blob) + return fileReaderReady(reader) + } + + function Body() { + this.bodyUsed = false + + this._initBody = function(body) { + this._bodyInit = body + if (typeof body === 'string') { + this._bodyText = body + } else if (support.blob && Blob.prototype.isPrototypeOf(body)) { + this._bodyBlob = body + } else if (support.formData && FormData.prototype.isPrototypeOf(body)) { + this._bodyFormData = body + } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { + this._bodyText = body.toString() + } else if (!body) { + this._bodyText = '' + } else if (support.arrayBuffer && ArrayBuffer.prototype.isPrototypeOf(body)) { + // Only support ArrayBuffers for POST method. + // Receiving ArrayBuffers happens via Blobs, instead. + } else { + throw new Error('unsupported BodyInit type') + } + + if (!this.headers.get('content-type')) { + if (typeof body === 'string') { + this.headers.set('content-type', 'text/plain;charset=UTF-8') + } else if (this._bodyBlob && this._bodyBlob.type) { + this.headers.set('content-type', this._bodyBlob.type) + } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { + this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8') + } + } + } + + if (support.blob) { + this.blob = function() { + var rejected = consumed(this) + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return Promise.resolve(this._bodyBlob) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as blob') + } else { + return Promise.resolve(new Blob([this._bodyText])) + } + } + + this.arrayBuffer = function() { + return this.blob().then(readBlobAsArrayBuffer) + } + + this.text = function() { + var rejected = consumed(this) + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return readBlobAsText(this._bodyBlob) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as text') + } else { + return Promise.resolve(this._bodyText) + } + } + } else { + this.text = function() { + var rejected = consumed(this) + return rejected ? rejected : Promise.resolve(this._bodyText) + } + } + + if (support.formData) { + this.formData = function() { + return this.text().then(decode) + } + } + + this.json = function() { + return this.text().then(JSON.parse) + } + + return this + } + + // HTTP methods whose capitalization should be normalized + var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'] + + function normalizeMethod(method) { + var upcased = method.toUpperCase() + return (methods.indexOf(upcased) > -1) ? upcased : method + } + + function Request(input, options) { + options = options || {} + var body = options.body + if (Request.prototype.isPrototypeOf(input)) { + if (input.bodyUsed) { + throw new TypeError('Already read') + } + this.url = input.url + this.credentials = input.credentials + if (!options.headers) { + this.headers = new Headers(input.headers) + } + this.method = input.method + this.mode = input.mode + if (!body) { + body = input._bodyInit + input.bodyUsed = true + } + } else { + this.url = input + } + + this.credentials = options.credentials || this.credentials || 'omit' + if (options.headers || !this.headers) { + this.headers = new Headers(options.headers) + } + this.method = normalizeMethod(options.method || this.method || 'GET') + this.mode = options.mode || this.mode || null + this.referrer = null + + if ((this.method === 'GET' || this.method === 'HEAD') && body) { + throw new TypeError('Body not allowed for GET or HEAD requests') + } + this._initBody(body) + } + + Request.prototype.clone = function() { + return new Request(this) + } + + function decode(body) { + var form = new FormData() + body.trim().split('&').forEach(function(bytes) { + if (bytes) { + var split = bytes.split('=') + var name = split.shift().replace(/\+/g, ' ') + var value = split.join('=').replace(/\+/g, ' ') + form.append(decodeURIComponent(name), decodeURIComponent(value)) + } + }) + return form + } + + function headers(xhr) { + var head = new Headers() + var pairs = (xhr.getAllResponseHeaders() || '').trim().split('\n') + pairs.forEach(function(header) { + var split = header.trim().split(':') + var key = split.shift().trim() + var value = split.join(':').trim() + head.append(key, value) + }) + return head + } + + Body.call(Request.prototype) + + function Response(bodyInit, options) { + if (!options) { + options = {} + } + + this.type = 'default' + this.status = options.status + this.ok = this.status >= 200 && this.status < 300 + this.statusText = options.statusText + this.headers = options.headers instanceof Headers ? options.headers : new Headers(options.headers) + this.url = options.url || '' + this._initBody(bodyInit) + } + + Body.call(Response.prototype) + + Response.prototype.clone = function() { + return new Response(this._bodyInit, { + status: this.status, + statusText: this.statusText, + headers: new Headers(this.headers), + url: this.url + }) + } + + Response.error = function() { + var response = new Response(null, {status: 0, statusText: ''}) + response.type = 'error' + return response + } + + var redirectStatuses = [301, 302, 303, 307, 308] + + Response.redirect = function(url, status) { + if (redirectStatuses.indexOf(status) === -1) { + throw new RangeError('Invalid status code') + } + + return new Response(null, {status: status, headers: {location: url}}) + } + + self.Headers = Headers + self.Request = Request + self.Response = Response + + self.fetch = function(input, init) { + return new Promise(function(resolve, reject) { + var request + if (Request.prototype.isPrototypeOf(input) && !init) { + request = input + } else { + request = new Request(input, init) + } + + var xhr = new XMLHttpRequest() + + function responseURL() { + if ('responseURL' in xhr) { + return xhr.responseURL + } + + // Avoid security warnings on getResponseHeader when not allowed by CORS + if (/^X-Request-URL:/m.test(xhr.getAllResponseHeaders())) { + return xhr.getResponseHeader('X-Request-URL') + } + + return + } + + xhr.onload = function() { + var options = { + status: xhr.status, + statusText: xhr.statusText, + headers: headers(xhr), + url: responseURL() + } + var body = 'response' in xhr ? xhr.response : xhr.responseText + resolve(new Response(body, options)) + } + + xhr.onerror = function() { + reject(new TypeError('Network request failed')) + } + + xhr.ontimeout = function() { + reject(new TypeError('Network request failed')) + } + + xhr.open(request.method, request.url, true) + + if (request.credentials === 'include') { + xhr.withCredentials = true + } + + if ('responseType' in xhr && support.blob) { + xhr.responseType = 'blob' + } + + request.headers.forEach(function(value, name) { + xhr.setRequestHeader(name, value) + }) + + xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit) + }) + } + self.fetch.polyfill = true +})(typeof self !== 'undefined' ? self : this); diff --git a/public/javascript/tables.js b/public/javascript/tables.js index 6afbf235..887d80b5 100644 --- a/public/javascript/tables.js +++ b/public/javascript/tables.js @@ -137,3 +137,32 @@ if (typeof moment.tz !== 'undefined') { } })(); } + +// setup SMTP check +var smtpForm = document.querySelector('form#smtp-verify'); +if (smtpForm) { + smtpForm.addEventListener('submit', function (e) { + e.preventDefault(); + + var form = document.getElementById('settings-form'); + var formData = new FormData(form); + var result = fetch('/settings/smtp-verify', { + method: 'POST', + body: formData, + credentials: 'same-origin' + }); + + var $btn = $('#verify-button').button('loading'); + + result.then(function (res) { + return res.json(); + }).then(function (data) { + alert(data.error ? 'Invalid SMTP settings\n' + data.error : data.message); + $btn.button('reset'); + }).catch(function (err) { + alert(err.message); + $btn.button('reset'); + }); + + }); +} diff --git a/routes/settings.js b/routes/settings.js index 1059960b..8826d3a0 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -8,6 +8,8 @@ let tools = require('../lib/tools'); let nodemailer = require('nodemailer'); let mailer = require('../lib/mailer'); let url = require('url'); +let multer = require('multer'); +let upload = multer(); let settings = require('../lib/models/settings'); @@ -104,61 +106,74 @@ router.post('/update', passport.parseForm, passport.csrfProtection, (req, res) = storeSettings(); }); -router.post('/smtp-verify', passport.parseForm, passport.csrfProtection, (req, res) => { - settings.list((err, configItems) => { - if (err) { - req.flash('danger', err.message || err); - return res.redirect('/settings'); +router.post('/smtp-verify', upload.array(), passport.parseForm, passport.csrfProtection, (req, res) => { + + let data = tools.convertKeys(req.body); + + // checkboxs are not included in value listing if left unchecked + ['smtpLog', 'smtpSelfSigned', 'smtpDisableAuth'].forEach(key => { + if (!data.hasOwnProperty(key)) { + data[key] = false; + } else { + data[key] = true; } - - let transport = nodemailer.createTransport({ - host: configItems.smtpHostname, - port: Number(configItems.smtpPort) || false, - secure: configItems.smtpEncryption === 'TLS', - ignoreTLS: configItems.smtpEncryption === 'NONE', - auth: configItems.smtpDisableAuth ? false : { - user: configItems.smtpUser, - pass: configItems.smtpPass - }, - tls: { - rejectUnauthorized: !configItems.smtpSelfSigned - } - }); - - transport.verify(err => { - if (err) { - let message = ''; - switch (err.code) { - case 'ECONNREFUSED': - message = 'Connection refused, check hostname and port.'; - break; - case 'ETIMEDOUT': - if ((err.message || '').indexOf('Greeting never received') === 0) { - if (configItems.smtpEncryption !== 'TLS') { - message = 'Did not receive greeting message from server. This might happen when connecting to a TLS port without using TLS.'; - } else { - message = 'Did not receive greeting message from server.'; - } - } else { - message = 'Connection timed out. Check your firewall settings, destination port is probably blocked.'; - } - break; - case 'EAUTH': - if (/\b5\.7\.0\b/.test(err.message) && configItems.smtpEncryption !== 'STARTTLS') { - message = 'Authentication not accepted, server expects STARTTLS to be used.'; - } else { - message = 'Authentication failed, check username and password.'; - } - - break; - } - req.flash('warning', (message || 'Failed SMTP verification.') + (err.response ? ' Server responded with: "' + err.response + '"' : '')); - } else { - req.flash('info', 'SMTP settings verified, ready to send some mail!'); - } - return res.redirect('/settings'); - }); }); + + let transport = nodemailer.createTransport({ + host: data.smtpHostname, + port: Number(data.smtpPort) || false, + secure: data.smtpEncryption === 'TLS', + ignoreTLS: data.smtpEncryption === 'NONE', + auth: data.smtpDisableAuth ? false : { + user: data.smtpUser, + pass: data.smtpPass + }, + tls: { + rejectUnauthorized: !data.smtpSelfSigned + } + }); + + transport.verify(err => { + if (err) { + let message = ''; + switch (err.code) { + case 'ECONNREFUSED': + message = 'Connection refused, check hostname and port.'; + break; + case 'ETIMEDOUT': + if ((err.message || '').indexOf('Greeting never received') === 0) { + if (data.smtpEncryption !== 'TLS') { + message = 'Did not receive greeting message from server. This might happen when connecting to a TLS port without using TLS.'; + } else { + message = 'Did not receive greeting message from server.'; + } + } else { + message = 'Connection timed out. Check your firewall settings, destination port is probably blocked.'; + } + break; + case 'EAUTH': + if (/\b5\.7\.0\b/.test(err.message) && data.smtpEncryption !== 'STARTTLS') { + message = 'Authentication not accepted, server expects STARTTLS to be used.'; + } else { + message = 'Authentication failed, check username and password.'; + } + + break; + } + if (!message && err.reason) { + message = err.reason; + } + + res.json({ + error: (message || 'Failed SMTP verification.') + (err.response ? ' Server responded with: "' + err.response + '"' : '') + }); + } else { + res.json({ + message: 'SMTP settings verified, ready to send some mail!' + }); + } + }); + }); module.exports = router; diff --git a/views/layout.hbs b/views/layout.hbs index 97923d02..daac5fcc 100644 --- a/views/layout.hbs +++ b/views/layout.hbs @@ -144,6 +144,7 @@ + {{#if useEditor}} diff --git a/views/settings.hbs b/views/settings.hbs index ac9b96ce..0e6bdd6e 100644 --- a/views/settings.hbs +++ b/views/settings.hbs @@ -11,7 +11,7 @@ -