mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-22 16:26:08 +08:00
[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:
parent
da74dc2c18
commit
60e6113f87
|
@ -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();
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) =>
|
||||||
|
|
|
@ -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)));
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue