From a57e4bdd20481f65cad4ef80dacdf9ce463b450a Mon Sep 17 00:00:00 2001 From: Christine Spang Date: Fri, 10 Mar 2017 12:21:04 -0800 Subject: [PATCH] [cloud-api] Verify SMTP credentials in /auth endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: This patch will prevent users from being able to connect accounts which sync mail but fail to send. This commit includes a couple pieces: * Adds a call to nodemailer's `verify()` function in the /auth endpoint * Adds Error object conversion for SMTP errors. Since we don't implement our own connection object or connection pool for SMTP, we simply wrap the couple places we call functions from nodemailer that connect to SMTP, namely SendmailClient's _send() and the new verify() call in /auth. * Moves RetryableError to the 'errors' module since it's now a base class for retryable IMAP //and// SMTP errors. * Moves the main `smtpConfig()` logic which used to live on the Account model into AuthHelpers so it can be shared between the Account model and the verify code. * Converts a few different places to use `import` syntax instead of `require` syntax for module imports. Apologies for not splitting this out into a separate diff—would have been a fair amount of work and looks not too difficult to skim over in the context of the rest of the patch. * Fixing a bug in a previous commit where erroring sends would crash because of using `this._transporter.options` instead of `this._transporter.transporter.options` Test Plan: manual Reviewers: evan, halla, juan Reviewed By: halla, juan Differential Revision: https://phab.nylas.com/D4200 --- .../src/flux/tasks/send-draft-task.es6 | 6 +- .../src/local-sync-worker/sync-worker.es6 | 5 +- .../syncback-task-runner.es6 | 4 +- packages/isomorphic-core/index.js | 3 +- packages/isomorphic-core/src/auth-helpers.es6 | 234 ++++++++++++++++++ packages/isomorphic-core/src/auth-helpers.js | 184 -------------- .../src/{errors.js => errors.es6} | 14 +- .../src/imap-connection-pool.es6 | 2 +- .../isomorphic-core/src/imap-connection.es6 | 3 +- .../src/{imap-errors.js => imap-errors.es6} | 68 ++--- .../isomorphic-core/src/models/account.js | 42 +--- .../isomorphic-core/src/sendmail-client.es6 | 34 +-- packages/isomorphic-core/src/smtp-errors.es6 | 64 +++++ 13 files changed, 371 insertions(+), 292 deletions(-) create mode 100644 packages/isomorphic-core/src/auth-helpers.es6 delete mode 100644 packages/isomorphic-core/src/auth-helpers.js rename packages/isomorphic-core/src/{errors.js => errors.es6} (62%) rename packages/isomorphic-core/src/{imap-errors.js => imap-errors.es6} (73%) create mode 100644 packages/isomorphic-core/src/smtp-errors.es6 diff --git a/packages/client-app/src/flux/tasks/send-draft-task.es6 b/packages/client-app/src/flux/tasks/send-draft-task.es6 index bb01e2899..87d92d45a 100644 --- a/packages/client-app/src/flux/tasks/send-draft-task.es6 +++ b/packages/client-app/src/flux/tasks/send-draft-task.es6 @@ -242,10 +242,12 @@ export default class SendDraftTask extends BaseDraftTask { const errorMessage = (err.body && err.body.message) || '' message = `Sorry, this message could not be sent, please try again.`; message += `\n\nReason: ${err.message}` - if (errorMessage.includes('Network Error')) { + if (errorMessage.includes('unable to reach your SMTP server')) { message = `Sorry, this message could not be sent. There was a network error, please make sure you are online.` } - if (errorMessage.includes('Invalid login')) { + if (errorMessage.includes('Incorrect SMTP username or password') || + errorMessage.includes('SMTP protocol error') || + errorMessage.includes('unable to look up your SMTP host')) { Actions.updateAccount(this.draft.accountId, {syncState: Account.SYNC_STATE_AUTH_FAILED}) message = `Sorry, this message could not be sent due to an authentication error. Please re-authenticate your account and try again.` } diff --git a/packages/client-sync/src/local-sync-worker/sync-worker.es6 b/packages/client-sync/src/local-sync-worker/sync-worker.es6 index 5f9f62683..052bf5a9b 100644 --- a/packages/client-sync/src/local-sync-worker/sync-worker.es6 +++ b/packages/client-sync/src/local-sync-worker/sync-worker.es6 @@ -1,5 +1,6 @@ import _ from 'underscore' import { + Errors, IMAPErrors, SendmailClient, MetricsReporter, @@ -352,7 +353,7 @@ class SyncWorker { // Check if we've encountered a retryable/network error. // If so, we don't want to save the error to the account, which will cause // a red box to show up. - if (error instanceof IMAPErrors.RetryableError) { + if (error instanceof Errors.RetryableError) { this._retryScheduler.nextDelay() return } @@ -421,7 +422,7 @@ class SyncWorker { const moreToSync = folders.some((f) => !f.isSyncComplete()) if (error != null) { - if (error instanceof IMAPErrors.RetryableError) { + if (error instanceof Errors.RetryableError) { interval = this._retryScheduler.currentDelay(); } else { interval = AC_SYNC_LOOP_INTERVAL_MS; diff --git a/packages/client-sync/src/local-sync-worker/syncback-task-runner.es6 b/packages/client-sync/src/local-sync-worker/syncback-task-runner.es6 index 81f1293c7..3f9ef8d41 100644 --- a/packages/client-sync/src/local-sync-worker/syncback-task-runner.es6 +++ b/packages/client-sync/src/local-sync-worker/syncback-task-runner.es6 @@ -1,5 +1,5 @@ import {Actions} from 'nylas-exports' -import {IMAPErrors} from 'isomorphic-core' +import {Errors} from 'isomorphic-core' import SyncbackTask from './syncback-tasks/syncback-task' import SyncbackTaskFactory from './syncback-task-factory'; @@ -184,7 +184,7 @@ class SyncbackTaskRunner { } catch (error) { const after = new Date(); - if (error instanceof IMAPErrors.RetryableError) { + if (error instanceof Errors.RetryableError) { Actions.recordUserEvent('Retrying syncback task', { accountId: this._account.id, provider: this._account.provider, diff --git a/packages/isomorphic-core/index.js b/packages/isomorphic-core/index.js index d99d8a5d4..b2bbea68a 100644 --- a/packages/isomorphic-core/index.js +++ b/packages/isomorphic-core/index.js @@ -7,11 +7,12 @@ module.exports = { Imap: require('imap'), Errors: require('./src/errors'), IMAPErrors: require('./src/imap-errors'), + SMTPErrors: require('./src/smtp-errors'), loadModels: require('./src/load-models'), AuthHelpers: require('./src/auth-helpers'), PromiseUtils: require('./src/promise-utils'), DatabaseTypes: require('./src/database-types'), - IMAPConnection: require('./src/imap-connection'), + IMAPConnection: require('./src/imap-connection').default, IMAPConnectionPool: require('./src/imap-connection-pool'), SendmailClient: require('./src/sendmail-client'), DeltaStreamBuilder: require('./src/delta-stream-builder'), diff --git a/packages/isomorphic-core/src/auth-helpers.es6 b/packages/isomorphic-core/src/auth-helpers.es6 new file mode 100644 index 000000000..0518bf03e --- /dev/null +++ b/packages/isomorphic-core/src/auth-helpers.es6 @@ -0,0 +1,234 @@ +import _ from 'underscore' +import Joi from 'joi' +import atob from 'atob'; +import nodemailer from 'nodemailer'; +import IMAPConnection from './imap-connection' +import {NylasError, RetryableError} from './errors' +import {convertSmtpError} from './smtp-errors' + +const imapSmtpSettings = Joi.object().keys({ + imap_host: [Joi.string().ip().required(), Joi.string().hostname().required()], + imap_port: Joi.number().integer().required(), + imap_username: Joi.string().required(), + imap_password: Joi.string().required(), + smtp_host: [Joi.string().ip().required(), Joi.string().hostname().required()], + smtp_port: Joi.number().integer().required(), + smtp_username: Joi.string().required(), + smtp_password: Joi.string().required(), + smtp_custom_config: Joi.object(), + ssl_required: Joi.boolean().required(), +}).required(); + +const resolvedGmailSettings = Joi.object().keys({ + xoauth2: Joi.string().required(), + expiry_date: Joi.number().integer().required(), +}).required(); + +const office365Settings = Joi.object().keys({ + name: Joi.string().required(), + type: Joi.string().valid('office365').required(), + email: Joi.string().required(), + password: Joi.string().required(), + username: Joi.string().required(), +}).required(); + +export const SUPPORTED_PROVIDERS = new Set( + ['gmail', 'office365', 'imap', 'icloud', 'yahoo', 'fastmail'] +); + +export function credentialsForProvider({provider, settings, email}) { + if (provider === "gmail") { + const connectionSettings = { + imap_username: email, + imap_host: 'imap.gmail.com', + imap_port: 993, + smtp_username: email, + smtp_host: 'smtp.gmail.com', + smtp_port: 465, + ssl_required: true, + } + const connectionCredentials = { + xoauth2: settings.xoauth2, + expiry_date: settings.expiry_date, + } + return {connectionSettings, connectionCredentials} + } else if (provider === "office365") { + const connectionSettings = { + imap_host: 'outlook.office365.com', + imap_port: 993, + ssl_required: true, + smtp_custom_config: { + host: 'smtp.office365.com', + port: 587, + secure: false, + tls: {ciphers: 'SSLv3'}, + }, + } + + const connectionCredentials = { + imap_username: email, + imap_password: settings.password || settings.imap_password, + smtp_username: email, + smtp_password: settings.password || settings.smtp_password, + } + return {connectionSettings, connectionCredentials} + } else if (SUPPORTED_PROVIDERS.has(provider)) { + const connectionSettings = _.pick(settings, [ + 'imap_host', 'imap_port', + 'smtp_host', 'smtp_port', + 'ssl_required', 'smtp_custom_config', + ]); + const connectionCredentials = _.pick(settings, [ + 'imap_username', 'imap_password', + 'smtp_username', 'smtp_password', + ]); + return {connectionSettings, connectionCredentials} + } + throw new Error(`Invalid provider: ${provider}`) +} + +function bearerToken(xoauth2) { + // We have to unpack the access token from the entire XOAuth2 + // token because it is re-packed during the SMTP connection login. + // https://github.com/nodemailer/smtp-connection/blob/master/lib/smtp-connection.js#L1418 + const bearer = "Bearer "; + const decoded = atob(xoauth2); + const tokenIndex = decoded.indexOf(bearer) + bearer.length; + return decoded.substring(tokenIndex, decoded.length - 2); +} + +export function smtpConfigFromSettings(provider, connectionSettings, connectionCredentials) { + let config; + const {smtp_host, smtp_port, ssl_required} = connectionSettings; + if (connectionSettings.smtp_custom_config) { + config = connectionSettings.smtp_custom_config; + } else { + config = { + host: smtp_host, + port: smtp_port, + secure: ssl_required, + }; + } + + if (provider === 'gmail') { + const {xoauth2} = connectionCredentials; + if (!xoauth2) { + throw new Error("Missing XOAuth2 Token") + } + + const token = bearerToken(xoauth2); + config.auth = { user: connectionSettings.smtp_username, xoauth2: token } + } else if (SUPPORTED_PROVIDERS.has(provider)) { + const {smtp_username, smtp_password} = connectionCredentials + config.auth = { user: smtp_username, pass: smtp_password} + } else { + throw new Error(`${provider} not yet supported`) + } + + return config; +} + +export function imapAuthRouteConfig() { + return { + description: 'Authenticates a new account.', + tags: ['accounts'], + auth: false, + validate: { + payload: { + email: Joi.string().email().required(), + name: Joi.string().required(), + provider: Joi.string().valid(...SUPPORTED_PROVIDERS).required(), + settings: Joi.alternatives().try(imapSmtpSettings, office365Settings, resolvedGmailSettings), + }, + }, + } +} + +export function imapAuthHandler(upsertAccount) { + const MAX_RETRIES = 2 + const authHandler = (request, reply, retryNum = 0) => { + const dbStub = {}; + const {email, provider, name} = request.payload; + + const connectionChecks = []; + const {connectionSettings, connectionCredentials} = credentialsForProvider(request.payload) + + // All IMAP accounts require a valid SMTP server for sending, and we never + // want to allow folks to connect accounts and find out later that they + // entered the wrong SMTP credentials. So verify here also! + const smtpConfig = smtpConfigFromSettings(provider, connectionSettings, connectionCredentials); + const smtpTransport = nodemailer.createTransport(Object.assign({ + connectionTimeout: 30000, + }, smtpConfig)); + const smtpVerifyPromise = smtpTransport.verify().catch((error) => { + throw convertSmtpError(error); + }) + + connectionChecks.push(smtpVerifyPromise); + connectionChecks.push(IMAPConnection.connect({ + settings: Object.assign({}, connectionSettings, connectionCredentials), + logger: request.logger, + db: dbStub, + })); + + Promise.all(connectionChecks).then((results) => { + for (const result of results) { + // close any IMAP connections we opened + if (result && result.end) { result.end(); } + } + + const accountParams = { + name: name, + provider: provider, + emailAddress: email, + connectionSettings: connectionSettings, + } + return upsertAccount(accountParams, connectionCredentials) + }) + .then(({account, token}) => { + const response = account.toJSON(); + response.account_token = token.value; + reply(JSON.stringify(response)); + return + }) + .catch((err) => { + const logger = request.logger.child({ + account_name: name, + account_provider: provider, + account_email: email, + connection_settings: connectionSettings, + error_name: err.name, + error_message: err.message, + error_tb: err.stack, + }) + + if (err instanceof RetryableError) { + if (retryNum < MAX_RETRIES) { + setTimeout(() => { + request.logger.info(`${err.name}. Retry #${retryNum + 1}`) + authHandler(request, reply, retryNum + 1) + }, 100) + return + } + logger.error('Encountered retryable error while attempting to authenticate') + reply({message: err.userMessage, type: "api_error"}).code(err.statusCode); + return + } + + logger.error("Error trying to authenticate") + let userMessage = "Please contact support@nylas.com. An unforeseen error has occurred."; + let statusCode = 500; + if (err instanceof NylasError) { + if (err.userMessage) { + userMessage = err.userMessage; + } + if (err.statusCode) { + statusCode = err.statusCode; + } + } + reply({message: userMessage, type: "api_error"}).code(statusCode); + return; + }) + } + return authHandler +} diff --git a/packages/isomorphic-core/src/auth-helpers.js b/packages/isomorphic-core/src/auth-helpers.js deleted file mode 100644 index c1ac47180..000000000 --- a/packages/isomorphic-core/src/auth-helpers.js +++ /dev/null @@ -1,184 +0,0 @@ -const _ = require('underscore') -const Joi = require('joi'); -const IMAPErrors = require('./imap-errors') -const IMAPConnection = require('./imap-connection') - -const imapSmtpSettings = Joi.object().keys({ - imap_host: [Joi.string().ip().required(), Joi.string().hostname().required()], - imap_port: Joi.number().integer().required(), - imap_username: Joi.string().required(), - imap_password: Joi.string().required(), - smtp_host: [Joi.string().ip().required(), Joi.string().hostname().required()], - smtp_port: Joi.number().integer().required(), - smtp_username: Joi.string().required(), - smtp_password: Joi.string().required(), - smtp_custom_config: Joi.object(), - ssl_required: Joi.boolean().required(), -}).required(); - -const resolvedGmailSettings = Joi.object().keys({ - xoauth2: Joi.string().required(), - expiry_date: Joi.number().integer().required(), -}).required(); - -const office365Settings = Joi.object().keys({ - name: Joi.string().required(), - type: Joi.string().valid('office365').required(), - email: Joi.string().required(), - password: Joi.string().required(), - username: Joi.string().required(), -}).required(); - -const USER_ERRORS = { - AUTH_500: "Please contact support@nylas.com. An unforeseen error has occurred.", - IMAP_AUTH: "Incorrect username or password", - IMAP_RETRY: "We were unable to reach your mail provider. Please try again.", - IMAP_CERT: "We couldn't make a secure connection to your mail provider. Please contact support@nylas.com.", -} - -const SUPPORTED_PROVIDERS = new Set( - ['gmail', 'office365', 'imap', 'icloud', 'yahoo', 'fastmail'] -); - -function credentialsForProvider({provider, settings, email}) { - if (provider === "gmail") { - const connectionSettings = { - imap_username: email, - imap_host: 'imap.gmail.com', - imap_port: 993, - smtp_username: email, - smtp_host: 'smtp.gmail.com', - smtp_port: 465, - ssl_required: true, - } - const connectionCredentials = { - xoauth2: settings.xoauth2, - expiry_date: settings.expiry_date, - } - return {connectionSettings, connectionCredentials} - } else if (provider === "office365") { - const connectionSettings = { - imap_host: 'outlook.office365.com', - imap_port: 993, - ssl_required: true, - smtp_custom_config: { - host: 'smtp.office365.com', - port: 587, - secure: false, - tls: {ciphers: 'SSLv3'}, - }, - } - - const connectionCredentials = { - imap_username: email, - imap_password: settings.password || settings.imap_password, - smtp_username: email, - smtp_password: settings.password || settings.smtp_password, - } - return {connectionSettings, connectionCredentials} - } else if (SUPPORTED_PROVIDERS.has(provider)) { - const connectionSettings = _.pick(settings, [ - 'imap_host', 'imap_port', - 'smtp_host', 'smtp_port', - 'ssl_required', 'smtp_custom_config', - ]); - const connectionCredentials = _.pick(settings, [ - 'imap_username', 'imap_password', - 'smtp_username', 'smtp_password', - ]); - return {connectionSettings, connectionCredentials} - } - throw new Error(`Invalid provider: ${provider}`) -} - -module.exports = { - SUPPORTED_PROVIDERS, - credentialsForProvider, - imapAuthRouteConfig() { - return { - description: 'Authenticates a new account.', - tags: ['accounts'], - auth: false, - validate: { - payload: { - email: Joi.string().email().required(), - name: Joi.string().required(), - provider: Joi.string().valid(...SUPPORTED_PROVIDERS).required(), - settings: Joi.alternatives().try(imapSmtpSettings, office365Settings, resolvedGmailSettings), - }, - }, - } - }, - - imapAuthHandler(upsertAccount) { - const MAX_RETRIES = 2 - const authHandler = (request, reply, retryNum = 0) => { - const dbStub = {}; - const connectionChecks = []; - const {email, provider, name} = request.payload; - - const {connectionSettings, connectionCredentials} = credentialsForProvider(request.payload) - - connectionChecks.push(IMAPConnection.connect({ - settings: Object.assign({}, connectionSettings, connectionCredentials), - logger: request.logger, - db: dbStub, - })); - - Promise.all(connectionChecks).then((conns) => { - for (const conn of conns) { - if (conn) { conn.end(); } - } - const accountParams = { - name: name, - provider: provider, - emailAddress: email, - connectionSettings: connectionSettings, - } - return upsertAccount(accountParams, connectionCredentials) - }) - .then(({account, token}) => { - const response = account.toJSON(); - response.account_token = token.value; - reply(JSON.stringify(response)); - return - }) - .catch((err) => { - const logger = request.logger.child({ - account_name: name, - account_provider: provider, - account_email: email, - connection_settings: connectionSettings, - error: err, - error_message: err.message, - }) - if (err instanceof IMAPErrors.IMAPAuthenticationError) { - logger.error({err}, 'Encountered authentication error while attempting to authenticate') - reply({message: USER_ERRORS.IMAP_AUTH, type: "api_error"}).code(401); - return - } - if (err instanceof IMAPErrors.IMAPCertificateError) { - logger.error({err}, 'Encountered certificate error while attempting to authenticate') - reply({message: USER_ERRORS.IMAP_CERT, type: "api_error"}).code(401); - return - } - if (err instanceof IMAPErrors.RetryableError) { - if (retryNum < MAX_RETRIES) { - setTimeout(() => { - request.logger.info(`IMAP Timeout. Retry #${retryNum + 1}`) - authHandler(request, reply, retryNum + 1) - }, 100) - return - } - logger.error({err}, 'Encountered retryable error while attempting to authenticate') - reply({message: USER_ERRORS.IMAP_RETRY, type: "api_error"}).code(408); - return - } - logger.error({err}, 'Encountered unknown error while attempting to authenticate') - reply({message: USER_ERRORS.AUTH_500, type: "api_error"}).code(500); - return - }) - } - return authHandler - }, -} diff --git a/packages/isomorphic-core/src/errors.js b/packages/isomorphic-core/src/errors.es6 similarity index 62% rename from packages/isomorphic-core/src/errors.js rename to packages/isomorphic-core/src/errors.es6 index 76b8320ce..c1d811572 100644 --- a/packages/isomorphic-core/src/errors.js +++ b/packages/isomorphic-core/src/errors.es6 @@ -1,5 +1,4 @@ - -class NylasError extends Error { +export class NylasError extends Error { toJSON() { let json = {} if (super.toJSON) { @@ -13,7 +12,7 @@ class NylasError extends Error { } } -class APIError extends NylasError { +export class APIError extends NylasError { constructor(message, statusCode, data) { super(message); this.statusCode = statusCode; @@ -21,7 +20,8 @@ class APIError extends NylasError { } } -module.exports = { - NylasError, - APIError, -} +/** + * An abstract base class that can be used to indicate Errors that may fix + * themselves when retried + */ +export class RetryableError extends NylasError { } diff --git a/packages/isomorphic-core/src/imap-connection-pool.es6 b/packages/isomorphic-core/src/imap-connection-pool.es6 index a99fcc60a..f420421b5 100644 --- a/packages/isomorphic-core/src/imap-connection-pool.es6 +++ b/packages/isomorphic-core/src/imap-connection-pool.es6 @@ -1,4 +1,4 @@ -const IMAPConnection = require('./imap-connection'); +const IMAPConnection = require('./imap-connection').default; const IMAPErrors = require('./imap-errors'); const {inDevMode} = require('./env-helpers') diff --git a/packages/isomorphic-core/src/imap-connection.es6 b/packages/isomorphic-core/src/imap-connection.es6 index d560ec09c..18447b602 100644 --- a/packages/isomorphic-core/src/imap-connection.es6 +++ b/packages/isomorphic-core/src/imap-connection.es6 @@ -31,7 +31,7 @@ const ONE_HOUR_SECS = 60 * 60; const AUTH_TIMEOUT_MS = 30 * 1000; const DEFAULT_SOCKET_TIMEOUT_MS = 30 * 1000; -class IMAPConnection extends EventEmitter { +export default class IMAPConnection extends EventEmitter { static DefaultSocketTimeout = DEFAULT_SOCKET_TIMEOUT_MS; @@ -393,4 +393,3 @@ class IMAPConnection extends EventEmitter { } IMAPConnection.Capabilities = Capabilities; -module.exports = IMAPConnection diff --git a/packages/isomorphic-core/src/imap-errors.js b/packages/isomorphic-core/src/imap-errors.es6 similarity index 73% rename from packages/isomorphic-core/src/imap-errors.js rename to packages/isomorphic-core/src/imap-errors.es6 index 794b469c5..aa024237e 100644 --- a/packages/isomorphic-core/src/imap-errors.js +++ b/packages/isomorphic-core/src/imap-errors.es6 @@ -1,28 +1,44 @@ -const {NylasError} = require('./errors') -/** - * An abstract base class that can be used to indicate IMAPErrors that may - * fix themselves when retried - */ -class RetryableError extends NylasError { } +import {NylasError, RetryableError} from './errors' + +export class IMAPRetryableError extends RetryableError { + constructor(msg) { + super(msg) + this.userMessage = "We were unable to reach your IMAP provider. Please try again."; + this.statusCode = 408; + } +} /** * IMAPErrors that originate from NodeIMAP. See `convertImapError` for * documentation on underlying causes */ -class IMAPSocketError extends RetryableError { } -class IMAPConnectionTimeoutError extends RetryableError { } -class IMAPAuthenticationTimeoutError extends RetryableError { } -class IMAPProtocolError extends NylasError { } -class IMAPAuthenticationError extends NylasError { } -class IMAPTransientAuthenticationError extends RetryableError { } +export class IMAPSocketError extends IMAPRetryableError { } +export class IMAPConnectionTimeoutError extends IMAPRetryableError { } +export class IMAPAuthenticationTimeoutError extends IMAPRetryableError { } +export class IMAPTransientAuthenticationError extends IMAPRetryableError { } -class IMAPConnectionNotReadyError extends RetryableError { +export class IMAPProtocolError extends NylasError { + constructor(msg) { + super(msg) + this.userMessage = "IMAP protocol error. Please contact support@nylas.com." + this.statusCode = 401 + } +} +export class IMAPAuthenticationError extends NylasError { + constructor(msg) { + super(msg) + this.userMessage = "Incorrect IMAP username or password."; + this.statusCode = 401; + } +} + +export class IMAPConnectionNotReadyError extends IMAPRetryableError { constructor(funcName) { super(`${funcName} - You must call connect() first.`); } } -class IMAPConnectionEndedError extends RetryableError { +export class IMAPConnectionEndedError extends IMAPRetryableError { constructor(msg = "The IMAP Connection was ended.") { super(msg); } @@ -33,7 +49,13 @@ class IMAPConnectionEndedError extends RetryableError { * of time, but not over the short spans of time in which it'd make sense * for us to retry. */ -class IMAPCertificateError extends NylasError { } +export class IMAPCertificateError extends NylasError { + constructor(msg) { + super(msg) + this.userMessage = "We couldn't make a secure connection to your SMTP server. Please contact support@nylas.com." + this.statusCode = 401 + } +} /** * IMAPErrors may come from: @@ -74,7 +96,7 @@ class IMAPCertificateError extends NylasError { } * Message: 'Timed out while authenticating with server' * */ -function convertImapError(imapError) { +export function convertImapError(imapError) { let error = imapError; if (/try again/i.test(imapError.message)) { @@ -126,17 +148,3 @@ function convertImapError(imapError) { error.source = imapError.source return error } - -module.exports = { - convertImapError, - RetryableError, - IMAPSocketError, - IMAPConnectionTimeoutError, - IMAPAuthenticationTimeoutError, - IMAPProtocolError, - IMAPAuthenticationError, - IMAPTransientAuthenticationError, - IMAPConnectionNotReadyError, - IMAPConnectionEndedError, - IMAPCertificateError, -}; diff --git a/packages/isomorphic-core/src/models/account.js b/packages/isomorphic-core/src/models/account.js index f34c75876..07899ca97 100644 --- a/packages/isomorphic-core/src/models/account.js +++ b/packages/isomorphic-core/src/models/account.js @@ -1,8 +1,7 @@ -const atob = require('atob') const crypto = require('crypto'); const {JSONColumn, JSONArrayColumn} = require('../database-types'); -const {SUPPORTED_PROVIDERS, credentialsForProvider} = require('../auth-helpers'); +const {credentialsForProvider, smtpConfigFromSettings} = require('../auth-helpers'); const {DB_ENCRYPTION_ALGORITHM, DB_ENCRYPTION_PASSWORD} = process.env; @@ -108,16 +107,6 @@ module.exports = (sequelize, Sequelize) => { } }, - bearerToken(xoauth2) { - // We have to unpack the access token from the entire XOAuth2 - // token because it is re-packed during the SMTP connection login. - // https://github.com/nodemailer/smtp-connection/blob/master/lib/smtp-connection.js#L1418 - const bearer = "Bearer "; - const decoded = atob(xoauth2); - const tokenIndex = decoded.indexOf(bearer) + bearer.length; - return decoded.substring(tokenIndex, decoded.length - 2); - }, - smtpConfig() { // We always call credentialsForProvider() here because n1Cloud // sometimes needs to send emails for accounts which did not have their @@ -127,34 +116,7 @@ module.exports = (sequelize, Sequelize) => { settings: Object.assign({}, this.decryptedCredentials(), this.connectionSettings), email: this.emailAddress, }); - let config; - const {smtp_host, smtp_port, ssl_required} = connectionSettings; - if (connectionSettings.smtp_custom_config) { - config = connectionSettings.smtp_custom_config; - } else { - config = { - host: smtp_host, - port: smtp_port, - secure: ssl_required, - }; - } - - if (this.provider === 'gmail') { - const {xoauth2} = connectionCredentials; - if (!xoauth2) { - throw new Error("Missing XOAuth2 Token") - } - - const token = this.bearerToken(xoauth2); - config.auth = { user: connectionSettings.smtp_username, xoauth2: token } - } else if (SUPPORTED_PROVIDERS.has(this.provider)) { - const {smtp_username, smtp_password} = connectionCredentials - config.auth = { user: smtp_username, pass: smtp_password} - } else { - throw new Error(`${this.provider} not yet supported`) - } - - return config; + return smtpConfigFromSettings(this.provider, connectionSettings, connectionCredentials); }, }, }); diff --git a/packages/isomorphic-core/src/sendmail-client.es6 b/packages/isomorphic-core/src/sendmail-client.es6 index eed870ca2..56f165abc 100644 --- a/packages/isomorphic-core/src/sendmail-client.es6 +++ b/packages/isomorphic-core/src/sendmail-client.es6 @@ -1,8 +1,9 @@ /* eslint no-useless-escape: 0 */ -const fs = require('fs'); -const nodemailer = require('nodemailer'); -const mailcomposer = require('mailcomposer'); -const {APIError} = require('./errors') +import fs from 'fs' +import nodemailer from 'nodemailer' +import mailcomposer from 'mailcomposer' +import {APIError} from './errors' +import {convertSmtpError} from './smtp-errors' const MAX_RETRIES = 1; @@ -27,10 +28,8 @@ class SendmailClient { try { results = await this._transporter.sendMail(msgData); } catch (err) { - // TODO: shouldn't retry on permanent errors like Invalid login - // TODO: should also wait between retries :( // Keep retrying for MAX_RETRIES - error = err; + error = convertSmtpError(err); this._logger.error(err); } if (!results) { @@ -47,22 +46,15 @@ class SendmailClient { } this._logger.error('Max sending retries reached'); - // TODO: figure out how to parse different errors, like in cloud-core - // https://github.com/nylas/cloud-core/blob/production/sync-engine/inbox/sendmail/smtp/postel.py#L354 - if (/invalid login/i.test(error.message)) { - throw new APIError(`Sending failed - Invalid login`, 401, {originalError: error}) + let userMessage = 'Sending failed'; + let statusCode = 500; + if (error && error.userMessage && error.statusCode) { + userMessage = `Sending failed - ${error.userMessage}`; + statusCode = error.statusCode; } - if (error.message.includes("getaddrinfo ENOTFOUND")) { - throw new APIError(`Sending failed - Network Error`, 401, {originalError: error}) - } - - if (error.message.includes("connect ETIMEDOUT")) { - throw new APIError('Sending failed - Network Error', 401, {originalError: error}) - } - - const {host, port, secure} = this._transporter.options; - throw new APIError('Sending failed', 500, { + const {host, port, secure} = this._transporter.transporter.options; + throw new APIError(userMessage, statusCode, { originalError: error, smtp_host: host, smtp_port: port, diff --git a/packages/isomorphic-core/src/smtp-errors.es6 b/packages/isomorphic-core/src/smtp-errors.es6 new file mode 100644 index 000000000..4bffeaaf5 --- /dev/null +++ b/packages/isomorphic-core/src/smtp-errors.es6 @@ -0,0 +1,64 @@ +import {NylasError, RetryableError} from './errors' + +export class SMTPRetryableError extends RetryableError { + constructor(msg) { + super(msg) + this.userMessage = "We were unable to reach your SMTP server. Please try again." + this.statusCode = 408 + } +} + +export class SMTPConnectionTimeoutError extends SMTPRetryableError { } +export class SMTPConnectionEndedError extends SMTPRetryableError { } +export class SMTPConnectionTLSError extends SMTPRetryableError { } + +export class SMTPProtocolError extends NylasError { + constructor(msg) { + super(msg) + this.userMessage = "SMTP protocol error. Please check your SMTP settings." + this.statusCode = 401 + } +} + +export class SMTPConnectionDNSError extends NylasError { + constructor(msg) { + super(msg) + this.userMessage = "We were unable to look up your SMTP host. Please check the SMTP server name." + this.statusCode = 401 + } +} +export class SMTPAuthenticationError extends NylasError { + constructor(msg) { + super(msg) + this.userMessage = "Incorrect SMTP username or password." + this.statusCode = 401 + } +} + +/* Nodemailer's errors are just regular old Error objects, so we have to + * test the error message to determine more about what they mean + */ +export function convertSmtpError(err) { + // TODO: what error is thrown if you're offline? + // TODO: what error is thrown if the message you're sending is too large? + if (/(?:connection timeout)|(?:connect etimedout)/i.test(err.message)) { + return new SMTPConnectionTimeoutError(err) + } + if (/connection closed/i.test(err.message)) { + return new SMTPConnectionEndedError(err) + } + if (/error initiating tls/i.test(err.message)) { + return new SMTPConnectionTLSError(err); + } + if (/getaddrinfo enotfound/i.test(err.message)) { + return new SMTPConnectionDNSError(err); + } + if (/unknown protocol/i.test(err.message)) { + return new SMTPProtocolError(err); + } + if (/(?:invalid login)|(?:username and password not accepted)|(?:incorrect username or password)/i.test(err.message)) { + return new SMTPAuthenticationError(err); + } + + return err; +}