[client-sync] Implement the /contacts/rankings endpoint

Summary:
Prior to Nylas Mail, the Nylas Cloud API provided an endpoint that
returned rankings for contacts which it computed based on how frequently
and how recently a user sent mail to a recipient. This diff reimplements
that functionality in Nylas Mail. This should improve contact
auto-complete when composing emails to frequently contacted recipients.

Test Plan: Run locally, verify that frequent contacts are suggested earlier

Reviewers: spang, evan, juan

Reviewed By: evan, juan

Maniphest Tasks: T7948

Differential Revision: https://phab.nylas.com/D4253
This commit is contained in:
Mark Hahnenberg 2017-03-22 14:34:03 -07:00
parent da74dc2c18
commit 60e6113f87
4 changed files with 142 additions and 12 deletions

View file

@ -1,21 +1,35 @@
import _ from 'underscore'
import moment from 'moment-timezone'
import { import {
AccountStore,
NylasAPI, NylasAPI,
NylasAPIRequest, NylasAPIRequest,
} from 'nylas-exports' } from 'nylas-exports'
import RefreshingJSONCache from './refreshing-json-cache' import RefreshingJSONCache from './refreshing-json-cache'
// Stores contact rankings // Stores contact rankings
class ContactRankingsCache extends RefreshingJSONCache { class ContactRankingsCache extends RefreshingJSONCache {
constructor(accountId) { constructor(accountId) {
super({ super({
key: `ContactRankingsFor${accountId}`, key: `ContactRankingsFor${accountId}`,
version: 1, version: 1,
refreshInterval: 60 * 60 * 1000 * 24, // one day refreshInterval: moment.duration(60, 'seconds').asMilliseconds(),
maxRefreshInterval: moment.duration(24, 'hours').asMilliseconds(),
}) })
this._accountId = accountId this._accountId = accountId
} }
_nextRefreshInterval() {
// For the first 15 minutes, refresh roughly once every minute so that the
// experience of composing drafts during initial is less annoying.
const initialLimit = (60 * 1000) + 15;
if (this.refreshInterval < initialLimit) {
return this.refreshInterval + 1;
}
// After the first 15 minutes, refresh twice as long each time up to the max.
return Math.min(this.refreshInterval * 2, this.maxRefreshInterval);
}
fetchData = (callback) => { fetchData = (callback) => {
if (NylasEnv.inSpecMode()) return if (NylasEnv.inSpecMode()) return
@ -37,6 +51,8 @@ class ContactRankingsCache extends RefreshingJSONCache {
rankings[email.toLowerCase()] = rank rankings[email.toLowerCase()] = rank
} }
callback(rankings) callback(rankings)
this.refreshInterval = this._nextRefreshInterval();
}) })
.catch((err) => { .catch((err) => {
console.warn(`Request for Contact Rankings failed for console.warn(`Request for Contact Rankings failed for
@ -45,4 +61,44 @@ class ContactRankingsCache extends RefreshingJSONCache {
} }
} }
export default ContactRankingsCache class ContactRankingsCacheManager {
constructor() {
this.accountCaches = {};
this.unsubscribers = [];
this.onAccountsChanged = _.debounce(this.onAccountsChanged, 100);
}
activate() {
this.onAccountsChanged();
this.unsubscribers = [AccountStore.listen(this.onAccountsChanged)];
}
deactivate() {
this.unsubscribers.forEach(unsub => unsub());
}
onAccountsChanged = () => {
const previousIDs = Object.keys(this.accountCaches);
const latestIDs = AccountStore.accounts().map(a => a.id);
if (_.isEqual(previousIDs, latestIDs)) {
return;
}
const newIDs = _.difference(latestIDs, previousIDs);
const removedIDs = _.difference(previousIDs, latestIDs);
console.log(`ContactRankingsCache: Updating contact rankings; added = ${latestIDs}, removed = ${removedIDs}`);
for (const newID of newIDs) {
this.accountCaches[newID] = new ContactRankingsCache(newID);
this.accountCaches[newID].start();
}
for (const removedID of removedIDs) {
this.accountCaches[removedID].end();
this.accountCaches[removedID] = null;
}
}
}
export default new ContactRankingsCacheManager();

View file

@ -1,9 +1,10 @@
import ContactRankingsCache from './contact-rankings-cache'
export function activate() { export function activate() {
// TODO This package only contains ContactRankingsCache, which should be ContactRankingsCache.activate();
// restored somewhere when we restore the contact rankings feature
} }
export function deactivate() { export function deactivate() {
ContactRankingsCache.deactivate();
} }

View file

@ -4,7 +4,7 @@ _ = require 'underscore'
class RefreshingJSONCache class RefreshingJSONCache
constructor: ({@key, @version, @refreshInterval}) -> constructor: ({@key, @version, @refreshInterval, @maxRefreshInterval}) ->
@_timeoutId = null @_timeoutId = null
start: -> start: ->
@ -13,6 +13,8 @@ class RefreshingJSONCache
# Look up existing data from db # Look up existing data from db
DatabaseStore.findJSONBlob(@key).then (json) => DatabaseStore.findJSONBlob(@key).then (json) =>
if json? and json.refreshInterval
@refreshInterval = json.refreshInterval
# Refresh immediately if json is missing or version is outdated. Otherwise, # Refresh immediately if json is missing or version is outdated. Otherwise,
# compute next refresh time and schedule # compute next refresh time and schedule
@ -44,6 +46,7 @@ class RefreshingJSONCache
version: @version version: @version
time: Date.now() time: Date.now()
value: newValue value: newValue
refreshInterval: @refreshInterval
}) })
fetchData: (callback) => fetchData: (callback) =>

View file

@ -1,5 +1,26 @@
const Joi = require('joi'); const Joi = require('joi');
const Serialization = require('../serialization'); const Serialization = require('../serialization');
const moment = require('moment-timezone');
const LOOKBACK_TIME = moment.duration(2, 'years').asMilliseconds();
const MIN_MESSAGE_WEIGHT = 0.01;
const getMessageWeight = (message, now) => {
const timeDiff = now - message.date.getTime();
const weight = 1.0 - (timeDiff / LOOKBACK_TIME);
return Math.max(weight, MIN_MESSAGE_WEIGHT);
};
const calculateContactScores = (messages, result) => {
const now = Date.now();
for (const message of messages) {
const weight = getMessageWeight(message, now);
for (const recipient of message.getRecipients()) {
const email = recipient.email.toLowerCase();
result[email] = result[email] ? (result[email] + weight) : weight;
}
}
};
module.exports = (server) => { module.exports = (server) => {
server.route({ server.route({
@ -75,12 +96,61 @@ module.exports = (server) => {
description: 'Returns contact rankings.', description: 'Returns contact rankings.',
notes: 'Notes go here', notes: 'Notes go here',
tags: ['contacts'], tags: ['contacts'],
response: {
schema: Serialization.jsonSchema('Contact'),
}, },
handler: async (request, reply) => {
const db = await request.getAccountDatabase()
const {Message, Label, Folder} = db;
const result = {};
let lastID = 0;
while (true) {
const messages = await Message.findAll({
attributes: ['rowid', 'id', 'to', 'cc', 'bcc', 'date'],
// We mark both Label and Folder as not required because we don't know
// whether the account uses folders or labels.
include: [
{
model: Label,
attributes: ['id', 'role'],
where: {role: 'sent'},
required: false, // This triggers a left outer join.
}, },
handler: (request, reply) => { {
reply('{}'); model: Folder,
attributes: ['id', 'role'],
where: {role: 'sent'},
required: false, // This triggers a left outer join.
},
],
where: {
'isDraft': false, // Don't include unsent things.
// Because we did a left outer join on the Folder and Label tables,
// we can get back records that have null for both the Folder and the
// Label (i.e. all things that aren't in the Sent folder). So we need
// to require that either the label ID or the folder ID is not null
// (i.e. that the message is in the Sent folder)
'$or': [
{'$labels.id$': {$ne: null}},
{'$folder.id$': {$ne: null}},
],
'$message.rowid$': {$gt: lastID},
},
// We don't use the `limit` option here because it causes sequelize
// to generate a subquery that doesn't have access to the joined
// labels/folders, causing a SQLite error to be thrown.
order: 'message.rowid ASC LIMIT 500',
});
if (messages.length === 0) {
break;
}
calculateContactScores(messages, result);
lastID = Math.max(...messages.map(m => m.dataValues.rowid));
}
reply(JSON.stringify(Object.entries(result)));
}, },
}) })
} }