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; +}