This commit is contained in:
Ben Gotow 2016-06-24 16:46:38 -07:00
parent 18ad15937f
commit 69e87cbf49
5 changed files with 235 additions and 71 deletions

View file

@ -8,6 +8,7 @@
"author": "Nylas",
"license": "ISC",
"dependencies": {
"googleapis": "^9.0.0",
"hapi": "13.4.1",
"hapi-auth-basic": "4.2.0",
"hapi-swagger": "6.1.0",

View file

@ -1,5 +1,7 @@
const Joi = require('joi');
const _ = require('underscore');
const google = require('googleapis');
const OAuth2 = google.auth.OAuth2;
const Serialization = require('../serialization');
const {
@ -8,6 +10,19 @@ const {
SyncPolicy,
} = require('nylas-core');
// TODO: Move these to config somehow / somewhere
const CLIENT_ID = '271342407743-nibas08fua1itr1utq9qjladbkv3esdm.apps.googleusercontent.com';
const CLIENT_SECRET = 'WhmxErj-ei6vJXLocNhBbfBF';
const REDIRECT_URL = 'http://localhost:5100/auth/gmail/oauthcallback';
const SCOPES = [
'https://www.googleapis.com/auth/userinfo.email', // email address
'https://www.googleapis.com/auth/userinfo.profile', // G+ profile
'https://mail.google.com/', // email
'https://www.google.com/m8/feeds', // contacts
'https://www.googleapis.com/auth/calendar', // calendar
];
const imapSmtpSettings = Joi.object().keys({
imap_host: [Joi.string().ip().required(), Joi.string().hostname().required()],
imap_port: Joi.number().integer().required(),
@ -26,6 +41,28 @@ const exchangeSettings = Joi.object().keys({
eas_server_host: [Joi.string().ip().required(), Joi.string().hostname().required()],
}).required();
const buildAccountWith = ({name, email, settings, credentials}) => {
return DatabaseConnector.forShared().then((db) => {
const {AccountToken, Account} = db;
const account = Account.build({
name: name,
emailAddress: email,
syncPolicy: SyncPolicy.defaultPolicy(),
connectionSettings: settings,
})
account.setCredentials(credentials);
return account.save().then((saved) =>
AccountToken.create({
AccountId: saved.id,
}).then((token) =>
Promise.resolve({account: saved, token: token})
)
);
});
}
module.exports = (server) => {
server.route({
method: 'POST',
@ -54,48 +91,118 @@ module.exports = (server) => {
},
},
handler: (request, reply) => {
const dbStub = {};
const connectionChecks = [];
const {settings, email, provider, name} = request.payload;
if (provider === 'imap') {
const dbStub = {};
const conn = new IMAPConnection(dbStub, settings);
connectionChecks.push(conn.connect())
connectionChecks.push(new IMAPConnection(dbStub, settings).connect())
}
Promise.all(connectionChecks).then(() => {
DatabaseConnector.forShared().then((db) => {
const {AccountToken, Account} = db;
const account = Account.build({
name: name,
emailAddress: email,
syncPolicy: SyncPolicy.defaultPolicy(),
connectionSettings: _.pick(settings, [
'imap_host', 'imap_port',
'smtp_host', 'smtp_port',
'ssl_required',
]),
})
account.setCredentials(_.pick(settings, [
buildAccountWith({
name,
email,
settings: _.pick(settings, [
'imap_host', 'imap_port',
'smtp_host', 'smtp_port',
'ssl_required',
]),
credentials: _.pick(settings, [
'imap_username', 'imap_password',
'smtp_username', 'smtp_password',
]));
account.save().then((saved) =>
AccountToken.create({
AccountId: saved.id,
}).then((accountToken) => {
const response = saved.toJSON();
response.token = accountToken.value;
reply(Serialization.jsonStringify(response));
})
);
]),
})
})
.then(({account, token}) => {
const response = account.toJSON();
response.token = token.value;
reply(Serialization.jsonStringify(response));
})
.catch((err) => {
// TODO: Lots more of this
reply({error: err.toString()});
})
},
});
};
server.route({
method: 'GET',
path: '/auth/gmail',
config: {
description: 'Redirects to Gmail OAuth',
notes: 'Notes go here',
tags: ['accounts'],
auth: false,
},
handler: (request, reply) => {
const oauthClient = new OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URL);
reply.redirect(oauthClient.generateAuthUrl({
access_type: 'offline',
prompt: 'consent',
scope: SCOPES,
}));
},
});
server.route({
method: 'GET',
path: '/auth/gmail/oauthcallback',
config: {
description: 'Authenticates a new account.',
notes: 'Notes go here',
tags: ['accounts'],
auth: false,
validate: {
query: {
code: Joi.string().required(),
},
},
},
handler: (request, reply) => {
const oauthClient = new OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URL);
oauthClient.getToken(request.query.code, (err, tokens) => {
if (err) {
reply(err.message).code(400);
return;
}
oauthClient.setCredentials(tokens);
google.oauth2({version: 'v2', auth: oauthClient}).userinfo.get((error, profile) => {
if (error) {
reply(error.message).code(400);
return;
}
const settings = {
imap_username: profile.email,
imap_host: 'imap.gmail.com',
imap_port: 993,
ssl_required: true,
}
const credentials = {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}
Promise.all([
new IMAPConnection({}, Object.assign({}, settings, credentials)).connect(),
])
.then(() =>
buildAccountWith({name: profile.name, email: profile.email, settings, credentials})
)
.then(({account, token}) => {
const response = account.toJSON();
response.token = token.value;
reply(Serialization.jsonStringify(response));
})
.catch((connectionErr) => {
// TODO: Lots more of this
reply({error: connectionErr.toString()});
});
});
});
},
});
}

View file

@ -1,5 +1,7 @@
const Imap = require('imap');
const EventEmitter = require('events');
const xoauth2 = require("xoauth2");
const _ = require('underscore');
const Capabilities = {
Gmail: 'X-GM-EXT-1',
@ -18,69 +20,114 @@ class IMAPConnection extends EventEmitter {
this._db = db;
this._queue = [];
this._current = null;
this._imap = Promise.promisifyAll(new Imap({
host: settings.imap_host,
port: settings.imap_port,
user: settings.imap_username,
password: settings.imap_password,
tls: settings.ssl_required,
}));
this._imap.once('end', () => {
console.log('Connection ended');
});
this._imap.on('alert', (msg) => {
console.log(`IMAP SERVER SAYS: ${msg}`)
})
// Emitted when new mail arrives in the currently open mailbox.
// Fix https://github.com/mscdex/node-imap/issues/445
let lastMailEventBox = null;
this._imap.on('mail', () => {
if (lastMailEventBox === this._imap._box.name) {
this.emit('mail');
}
lastMailEventBox = this._imap._box.name
});
// Emitted if the UID validity value for the currently open mailbox
// changes during the current session.
this._imap.on('uidvalidity', () => this.emit('uidvalidity'))
// Emitted when message metadata (e.g. flags) changes externally.
this._imap.on('update', () => this.emit('update'))
}
serverSupports(cap) {
this._imap.serverSupports(cap);
this._settings = settings;
}
connect() {
if (!this._connectPromise) {
this._connectPromise = new Promise((resolve, reject) => {
this._imap.once('ready', resolve);
this._imap.once('error', reject);
this._imap.connect();
});
this._connectPromise = this._resolveIMAPSettings().then((settings) =>
this._buildUnderlyingConnection(settings)
)
}
return this._connectPromise;
}
_resolveIMAPSettings() {
const result = {
host: this._settings.imap_host,
port: this._settings.imap_port,
user: this._settings.imap_username,
password: this._settings.imap_password,
tls: this._settings.ssl_required,
}
if (this._settings.refresh_token) {
const xoauthFields = ['client_id', 'client_secret', 'imap_username', 'refresh_token'];
if (Object.keys(_.pick(this._settings, xoauthFields)).length !== 4) {
throw new Error(`IMAPConnection: Expected ${xoauthFields.join(',')} when given refresh_token`)
}
return new Promise((resolve, reject) => {
xoauth2.createXOAuth2Generator({
clientId: this._settings.client_id,
clientSecret: this._settings.client_secret,
user: this._settings.imap_username,
refreshToken: this._settings.refresh_token,
}).getToken((err, token) => {
if (err) { return reject(err) }
delete result.password;
result.xoauth2 = token;
return resolve(result);
});
});
}
return Promise.resolve(result);
}
_buildUnderlyingConnection(settings) {
return new Promise((resolve, reject) => {
this._imap = Promise.promisifyAll(new Imap(settings));
this._imap.once('end', () => {
console.log('Connection ended');
});
this._imap.on('alert', (msg) => {
console.log(`IMAP SERVER SAYS: ${msg}`)
})
// Emitted when new mail arrives in the currently open mailbox.
// Fix https://github.com/mscdex/node-imap/issues/445
let lastMailEventBox = null;
this._imap.on('mail', () => {
if (lastMailEventBox === this._imap._box.name) {
this.emit('mail');
}
lastMailEventBox = this._imap._box.name
});
// Emitted if the UID validity value for the currently open mailbox
// changes during the current session.
this._imap.on('uidvalidity', () => this.emit('uidvalidity'))
// Emitted when message metadata (e.g. flags) changes externally.
this._imap.on('update', () => this.emit('update'))
this._imap.once('ready', resolve);
this._imap.once('error', reject);
this._imap.connect();
});
}
end() {
this._queue = [];
this._imap.end();
}
serverSupports(cap) {
if (!this._imap) {
throw new Error("IMAPConnection.serverSupports: You need to call connect() first.")
}
this._imap.serverSupports(cap);
}
openBox(box) {
if (!this._imap) {
throw new Error("IMAPConnection.openBox: You need to call connect() first.")
}
return this._imap.openBoxAsync(box, true);
}
getBoxes() {
if (!this._imap) {
throw new Error("IMAPConnection.getBoxes: You need to call connect() first.")
}
return this._imap.getBoxesAsync();
}
fetch(range, messageCallback) {
if (!this._imap) {
throw new Error("IMAPConnection.fetch: You need to call connect() first.")
}
return new Promise((resolve, reject) => {
const f = this._imap.fetch(range, {
bodies: ['HEADER', 'TEXT'],
@ -94,6 +141,9 @@ class IMAPConnection extends EventEmitter {
}
fetchMessages(uids, messageCallback) {
if (!this._imap) {
throw new Error("IMAPConnection.fetchMessages: You need to call connect() first.")
}
if (uids.length === 0) {
return Promise.resolve();
}
@ -101,6 +151,9 @@ class IMAPConnection extends EventEmitter {
}
fetchUIDAttributes(range) {
if (!this._imap) {
throw new Error("IMAPConnection.fetchUIDAttributes: You need to call connect() first.")
}
return new Promise((resolve, reject) => {
const latestUIDAttributes = {};
const f = this._imap.fetch(range, {});
@ -146,6 +199,9 @@ class IMAPConnection extends EventEmitter {
}
runOperation(operation) {
if (!this._imap) {
throw new Error("IMAPConnection.runOperation: You need to call connect() first.")
}
return new Promise((resolve, reject) => {
this._queue.push({operation, resolve, reject});
if (this._imap.state === 'authenticated' && !this._current) {

View file

@ -158,7 +158,7 @@ class SyncProcessManager {
addWorkerForAccountId(accountId) {
DatabaseConnector.forShared().then(({Account}) => {
Account.find({where: {id: accountId}}).then((account) => {
if (!account) {
if (!account || this._workers[account.id]) {
return;
}
DatabaseConnector.forAccount(account.id).then((db) => {

View file

@ -80,7 +80,7 @@ class SyncWorker {
if (!settings || !settings.imap_host) {
throw new Error("ensureConnection: There are no IMAP connection settings for this account.")
}
if (!credentials || !credentials.imap_username) {
if (!credentials) {
throw new Error("ensureConnection: There are no IMAP connection credentials for this account.")
}