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 {
|
||||
AccountStore,
|
||||
NylasAPI,
|
||||
NylasAPIRequest,
|
||||
} from 'nylas-exports'
|
||||
import RefreshingJSONCache from './refreshing-json-cache'
|
||||
|
||||
|
||||
// Stores contact rankings
|
||||
class ContactRankingsCache extends RefreshingJSONCache {
|
||||
constructor(accountId) {
|
||||
super({
|
||||
key: `ContactRankingsFor${accountId}`,
|
||||
version: 1,
|
||||
refreshInterval: 60 * 60 * 1000 * 24, // one day
|
||||
refreshInterval: moment.duration(60, 'seconds').asMilliseconds(),
|
||||
maxRefreshInterval: moment.duration(24, 'hours').asMilliseconds(),
|
||||
})
|
||||
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) => {
|
||||
if (NylasEnv.inSpecMode()) return
|
||||
|
||||
|
@ -37,6 +51,8 @@ class ContactRankingsCache extends RefreshingJSONCache {
|
|||
rankings[email.toLowerCase()] = rank
|
||||
}
|
||||
callback(rankings)
|
||||
|
||||
this.refreshInterval = this._nextRefreshInterval();
|
||||
})
|
||||
.catch((err) => {
|
||||
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() {
|
||||
// TODO This package only contains ContactRankingsCache, which should be
|
||||
// restored somewhere when we restore the contact rankings feature
|
||||
ContactRankingsCache.activate();
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
|
||||
ContactRankingsCache.deactivate();
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ _ = require 'underscore'
|
|||
|
||||
class RefreshingJSONCache
|
||||
|
||||
constructor: ({@key, @version, @refreshInterval}) ->
|
||||
constructor: ({@key, @version, @refreshInterval, @maxRefreshInterval}) ->
|
||||
@_timeoutId = null
|
||||
|
||||
start: ->
|
||||
|
@ -13,6 +13,8 @@ class RefreshingJSONCache
|
|||
|
||||
# Look up existing data from db
|
||||
DatabaseStore.findJSONBlob(@key).then (json) =>
|
||||
if json? and json.refreshInterval
|
||||
@refreshInterval = json.refreshInterval
|
||||
|
||||
# Refresh immediately if json is missing or version is outdated. Otherwise,
|
||||
# compute next refresh time and schedule
|
||||
|
@ -44,6 +46,7 @@ class RefreshingJSONCache
|
|||
version: @version
|
||||
time: Date.now()
|
||||
value: newValue
|
||||
refreshInterval: @refreshInterval
|
||||
})
|
||||
|
||||
fetchData: (callback) =>
|
||||
|
|
|
@ -1,5 +1,26 @@
|
|||
const Joi = require('joi');
|
||||
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) => {
|
||||
server.route({
|
||||
|
@ -75,12 +96,61 @@ module.exports = (server) => {
|
|||
description: 'Returns contact rankings.',
|
||||
notes: 'Notes go here',
|
||||
tags: ['contacts'],
|
||||
response: {
|
||||
schema: Serialization.jsonSchema('Contact'),
|
||||
},
|
||||
},
|
||||
handler: (request, reply) => {
|
||||
reply('{}');
|
||||
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.
|
||||
},
|
||||
{
|
||||
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