2016-06-22 08:51:24 +08:00
|
|
|
const _ = require('underscore');
|
2016-06-28 07:05:31 +08:00
|
|
|
const Imap = require('imap');
|
|
|
|
|
2016-06-23 05:41:32 +08:00
|
|
|
const {processMessage} = require(`nylas-message-processor`);
|
2016-06-23 08:19:48 +08:00
|
|
|
const {IMAPConnection} = require('nylas-core');
|
|
|
|
const {Capabilities} = IMAPConnection;
|
2016-06-21 05:57:54 +08:00
|
|
|
|
2016-06-22 05:58:20 +08:00
|
|
|
const MessageFlagAttributes = ['id', 'CategoryUID', 'unread', 'starred']
|
2016-06-21 05:44:02 +08:00
|
|
|
|
2016-06-24 02:20:47 +08:00
|
|
|
class FetchMessagesInCategory {
|
2016-06-21 08:33:23 +08:00
|
|
|
constructor(category, options) {
|
2016-06-26 16:57:33 +08:00
|
|
|
this._imap = null
|
|
|
|
this._box = null
|
|
|
|
this._db = null
|
2016-06-21 05:44:02 +08:00
|
|
|
this._category = category;
|
2016-06-21 08:33:23 +08:00
|
|
|
this._options = options;
|
2016-06-21 05:44:02 +08:00
|
|
|
if (!this._category) {
|
2016-06-28 07:01:21 +08:00
|
|
|
throw new NylasError("FetchMessagesInCategory requires a category")
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
description() {
|
2016-06-24 02:20:47 +08:00
|
|
|
return `FetchMessagesInCategory (${this._category.name} - ${this._category.id})\n Options: ${JSON.stringify(this._options)}`;
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
|
|
|
|
2016-06-22 05:58:20 +08:00
|
|
|
_getLowerBoundUID(count) {
|
2016-06-21 08:33:23 +08:00
|
|
|
return count ? Math.max(1, this._box.uidnext - count) : 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
_recoverFromUIDInvalidity() {
|
2016-06-22 05:58:20 +08:00
|
|
|
// UID invalidity means the server has asked us to delete all the UIDs for
|
2016-06-28 07:05:31 +08:00
|
|
|
// this folder and start from scratch. Instead of deleting all the messages,
|
|
|
|
// we just remove the category ID and UID. We may re-assign the same message
|
|
|
|
// the same UID. Otherwise they're eventually garbage collected.
|
2016-06-22 05:58:20 +08:00
|
|
|
const {Message} = this._db;
|
2016-06-21 08:33:23 +08:00
|
|
|
return this._db.sequelize.transaction((transaction) =>
|
2016-06-22 05:58:20 +08:00
|
|
|
Message.update({
|
|
|
|
CategoryUID: null,
|
|
|
|
CategoryId: null,
|
|
|
|
}, {
|
|
|
|
transaction: transaction,
|
2016-06-21 08:33:23 +08:00
|
|
|
where: {
|
|
|
|
CategoryId: this._category.id,
|
|
|
|
},
|
2016-06-22 05:58:20 +08:00
|
|
|
})
|
2016-06-21 08:33:23 +08:00
|
|
|
)
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
|
|
|
|
2016-06-22 05:58:20 +08:00
|
|
|
_createAndUpdateMessages(remoteUIDAttributes, localMessageAttributes) {
|
|
|
|
const messageAttributesMap = {};
|
|
|
|
for (const msg of localMessageAttributes) {
|
|
|
|
messageAttributesMap[msg.CategoryUID] = msg;
|
|
|
|
}
|
|
|
|
|
|
|
|
const createdUIDs = [];
|
|
|
|
const changedMessages = [];
|
|
|
|
|
|
|
|
Object.keys(remoteUIDAttributes).forEach((uid) => {
|
|
|
|
const msg = messageAttributesMap[uid];
|
|
|
|
const flags = remoteUIDAttributes[uid].flags;
|
|
|
|
|
|
|
|
if (!msg) {
|
|
|
|
createdUIDs.push(uid);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const unread = !flags.includes('\\Seen');
|
|
|
|
const starred = flags.includes('\\Flagged');
|
|
|
|
|
|
|
|
if (msg.unread !== unread || msg.starred !== starred) {
|
|
|
|
msg.unread = unread;
|
|
|
|
msg.starred = starred;
|
|
|
|
changedMessages.push(msg);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
console.log(` -- found ${createdUIDs.length} new messages`)
|
|
|
|
console.log(` -- found ${changedMessages.length} flag changes`)
|
|
|
|
|
|
|
|
return Promise.props({
|
2016-06-26 16:57:33 +08:00
|
|
|
creates: this._fetchMessagesAndQueueForProcessing(createdUIDs),
|
2016-06-22 05:58:20 +08:00
|
|
|
updates: this._db.sequelize.transaction((transaction) =>
|
|
|
|
Promise.all(changedMessages.map(m => m.save({
|
|
|
|
fields: MessageFlagAttributes,
|
|
|
|
transaction,
|
|
|
|
})))
|
|
|
|
),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
_removeDeletedMessages(remoteUIDAttributes, localMessageAttributes) {
|
|
|
|
const {Message} = this._db;
|
|
|
|
|
|
|
|
const removedUIDs = localMessageAttributes
|
|
|
|
.filter(msg => !remoteUIDAttributes[msg.CategoryUID])
|
|
|
|
.map(msg => msg.CategoryUID)
|
|
|
|
|
|
|
|
console.log(` -- found ${removedUIDs.length} messages no longer in the folder`)
|
2016-06-21 05:44:02 +08:00
|
|
|
|
|
|
|
if (removedUIDs.length === 0) {
|
|
|
|
return Promise.resolve();
|
|
|
|
}
|
|
|
|
return this._db.sequelize.transaction((transaction) =>
|
2016-06-22 05:58:20 +08:00
|
|
|
Message.update({
|
|
|
|
CategoryUID: null,
|
|
|
|
CategoryId: null,
|
|
|
|
}, {
|
|
|
|
transaction,
|
2016-06-21 08:33:23 +08:00
|
|
|
where: {
|
|
|
|
CategoryId: this._category.id,
|
2016-06-22 05:58:20 +08:00
|
|
|
CategoryUID: removedUIDs,
|
2016-06-21 08:33:23 +08:00
|
|
|
},
|
2016-06-22 05:58:20 +08:00
|
|
|
})
|
2016-06-21 05:44:02 +08:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2016-06-28 07:05:31 +08:00
|
|
|
_getDesiredMIMEParts(struct) {
|
|
|
|
const desired = [];
|
|
|
|
const available = [];
|
|
|
|
const unseen = [struct];
|
|
|
|
while (unseen.length > 0) {
|
|
|
|
const part = unseen.shift();
|
|
|
|
if (part instanceof Array) {
|
|
|
|
unseen.push(...part);
|
|
|
|
} else {
|
|
|
|
const mimetype = `${part.type}/${part.subtype}`;
|
|
|
|
available.push(mimetype);
|
|
|
|
if (['text/plain', 'text/html', 'application/pgp-encrypted'].includes(mimetype)) {
|
|
|
|
desired.push({id: part.partID, mimetype});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (desired.length === 0) {
|
|
|
|
console.warn(`Could not find good part. Options are: ${available.join(', ')}`)
|
|
|
|
}
|
|
|
|
|
|
|
|
return desired;
|
|
|
|
}
|
|
|
|
|
2016-06-26 16:57:33 +08:00
|
|
|
_fetchMessagesAndQueueForProcessing(range) {
|
2016-06-28 07:05:31 +08:00
|
|
|
const uidsByPart = {};
|
|
|
|
|
|
|
|
const $structs = this._box.fetch(range, {struct: true})
|
|
|
|
$structs.subscribe(({attributes}) => {
|
|
|
|
const desiredParts = this._getDesiredMIMEParts(attributes.struct);
|
|
|
|
if (desiredParts.length === 0) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const key = JSON.stringify(desiredParts);
|
|
|
|
uidsByPart[key] = uidsByPart[key] || [];
|
|
|
|
uidsByPart[key].push(attributes.uid);
|
|
|
|
});
|
|
|
|
|
|
|
|
return $structs.toPromise().then(() => {
|
|
|
|
return Promise.each(Object.keys(uidsByPart), (key) => {
|
|
|
|
const uids = uidsByPart[key];
|
|
|
|
const desiredParts = JSON.parse(key);
|
|
|
|
const bodies = ['HEADER'].concat(desiredParts.map(p => p.id));
|
|
|
|
console.log(`Fetching parts ${key} for ${uids.length} messages`)
|
|
|
|
|
|
|
|
const $body = this._box.fetch(uids, {bodies, struct: true})
|
|
|
|
$body.subscribe((msg) => {
|
|
|
|
msg.body = {};
|
|
|
|
for (const {id, mimetype} of desiredParts) {
|
|
|
|
msg.body[mimetype] = msg.parts[id];
|
|
|
|
}
|
|
|
|
this._processMessage(msg);
|
|
|
|
});
|
|
|
|
return $body.toPromise();
|
|
|
|
})
|
|
|
|
});
|
2016-06-26 16:57:33 +08:00
|
|
|
}
|
2016-06-21 05:44:02 +08:00
|
|
|
|
2016-06-29 02:32:15 +08:00
|
|
|
_createFilesFromStruct({message, struct}) {
|
|
|
|
const {File} = this._db
|
|
|
|
for (const part of struct) {
|
|
|
|
if (part.constructor === Array) {
|
|
|
|
this._createFilesFromStruct({message, struct: part})
|
|
|
|
} else if (part.disposition) {
|
|
|
|
let filename = null
|
|
|
|
if (part.disposition.params) {
|
2016-06-29 04:56:57 +08:00
|
|
|
filename = part.disposition.params.filename
|
2016-06-29 02:32:15 +08:00
|
|
|
}
|
|
|
|
File.create({
|
2016-06-29 04:55:00 +08:00
|
|
|
filename: filename,
|
|
|
|
contentId: part.partID,
|
|
|
|
contentType: `${part.type}/${part.subtype}`,
|
2016-06-29 02:32:15 +08:00
|
|
|
size: part.size,
|
|
|
|
})
|
|
|
|
.then((file) => {
|
|
|
|
file.setMessage(message)
|
|
|
|
message.addFile(file)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-06-26 16:57:33 +08:00
|
|
|
_processMessage({attributes, headers, body}) {
|
|
|
|
const {Message, accountId} = this._db;
|
2016-06-21 05:44:02 +08:00
|
|
|
const hash = Message.hashForHeaders(headers);
|
2016-06-28 07:05:31 +08:00
|
|
|
|
2016-06-22 05:58:20 +08:00
|
|
|
const values = {
|
|
|
|
hash: hash,
|
2016-06-28 07:05:31 +08:00
|
|
|
body: body['text/html'] || body['text/plain'] || body['application/pgp-encrypted'] || '',
|
|
|
|
snippet: body['text/plain'] || null,
|
2016-06-22 05:58:20 +08:00
|
|
|
unread: !attributes.flags.includes('\\Seen'),
|
|
|
|
starred: attributes.flags.includes('\\Flagged'),
|
|
|
|
date: attributes.date,
|
|
|
|
CategoryUID: attributes.uid,
|
2016-06-21 05:44:02 +08:00
|
|
|
CategoryId: this._category.id,
|
2016-06-28 07:05:31 +08:00
|
|
|
headers: Imap.parseHeader(headers),
|
2016-06-22 05:58:20 +08:00
|
|
|
}
|
2016-06-28 07:05:31 +08:00
|
|
|
|
2016-06-28 07:38:40 +08:00
|
|
|
values.messageId = values.headers['message-id'][0];
|
|
|
|
values.subject = values.headers.subject[0];
|
|
|
|
|
2016-06-22 05:58:20 +08:00
|
|
|
Message.find({where: {hash}}).then((existing) => {
|
|
|
|
if (existing) {
|
|
|
|
Object.assign(existing, values);
|
2016-06-28 07:05:31 +08:00
|
|
|
existing.save();
|
|
|
|
return;
|
2016-06-22 05:58:20 +08:00
|
|
|
}
|
2016-06-28 07:05:31 +08:00
|
|
|
|
2016-06-29 02:32:15 +08:00
|
|
|
Message.create(values).then((created) => {
|
|
|
|
this._createFilesFromStruct({message: created, struct: attributes.struct})
|
2016-06-28 07:05:31 +08:00
|
|
|
processMessage({accountId, messageId: created.id})
|
2016-06-29 02:32:15 +08:00
|
|
|
})
|
2016-06-22 05:58:20 +08:00
|
|
|
})
|
2016-06-28 07:05:31 +08:00
|
|
|
|
|
|
|
return null;
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
|
|
|
|
2016-06-21 08:33:23 +08:00
|
|
|
_openMailboxAndEnsureValidity() {
|
2016-06-26 16:57:33 +08:00
|
|
|
return this._imap.openBox(this._category.name)
|
|
|
|
.then((box) => {
|
2016-06-21 05:44:02 +08:00
|
|
|
if (box.persistentUIDs === false) {
|
2016-06-28 07:01:21 +08:00
|
|
|
return Promise.reject(new NylasError("Mailbox does not support persistentUIDs."))
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
|
|
|
if (box.uidvalidity !== this._category.syncState.uidvalidity) {
|
2016-06-26 16:57:33 +08:00
|
|
|
return this._recoverFromUIDInvalidity()
|
|
|
|
.then(() => Promise.resolve(box))
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
2016-06-26 16:57:33 +08:00
|
|
|
return Promise.resolve(box);
|
2016-06-21 05:44:02 +08:00
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
_fetchUnseenMessages() {
|
|
|
|
const savedSyncState = this._category.syncState;
|
2016-06-22 05:58:20 +08:00
|
|
|
const boxSyncState = {
|
2016-06-21 05:44:02 +08:00
|
|
|
uidnext: this._box.uidnext,
|
|
|
|
uidvalidity: this._box.uidvalidity,
|
|
|
|
}
|
|
|
|
|
2016-06-22 05:58:20 +08:00
|
|
|
const {limit} = this._options;
|
|
|
|
let range = `${this._getLowerBoundUID(limit)}:*`;
|
|
|
|
|
|
|
|
console.log(` - fetching unseen messages ${range}`)
|
|
|
|
|
2016-06-21 05:44:02 +08:00
|
|
|
if (savedSyncState.uidnext) {
|
2016-06-22 05:58:20 +08:00
|
|
|
if (savedSyncState.uidnext === boxSyncState.uidnext) {
|
|
|
|
console.log(" --- uidnext matches, nothing more to fetch")
|
2016-06-21 05:44:02 +08:00
|
|
|
return Promise.resolve();
|
|
|
|
}
|
2016-06-21 08:33:23 +08:00
|
|
|
range = `${savedSyncState.uidnext}:*`
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
|
|
|
|
2016-06-26 16:57:33 +08:00
|
|
|
return this._fetchMessagesAndQueueForProcessing(range).then(() => {
|
2016-06-22 05:58:20 +08:00
|
|
|
console.log(` - finished fetching unseen messages`);
|
2016-06-22 08:51:24 +08:00
|
|
|
return this.updateCategorySyncState({
|
2016-06-22 05:58:20 +08:00
|
|
|
uidnext: boxSyncState.uidnext,
|
|
|
|
uidvalidity: boxSyncState.uidvalidity,
|
|
|
|
timeFetchedUnseen: Date.now(),
|
|
|
|
});
|
2016-06-26 16:57:33 +08:00
|
|
|
})
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
|
|
|
|
2016-06-24 02:20:47 +08:00
|
|
|
_shouldRunDeepScan() {
|
|
|
|
const {timeDeepScan} = this._category.syncState;
|
|
|
|
return Date.now() - (timeDeepScan || 0) > this._options.deepFolderScan
|
|
|
|
}
|
|
|
|
|
|
|
|
_runDeepScan(range) {
|
2016-06-24 04:15:30 +08:00
|
|
|
const {Message} = this._db;
|
2016-06-28 07:05:31 +08:00
|
|
|
console.log("fetchUIDAttributes START")
|
2016-06-26 16:57:33 +08:00
|
|
|
return this._box.fetchUIDAttributes(range)
|
2016-06-28 07:05:31 +08:00
|
|
|
.then((remoteUIDAttributes) => {
|
|
|
|
console.log(`fetchUIDAttributes FINISHED - ${Object.keys(remoteUIDAttributes).length} items returned`)
|
|
|
|
return Message.findAll({
|
2016-06-24 02:20:47 +08:00
|
|
|
where: {CategoryId: this._category.id},
|
|
|
|
attributes: MessageFlagAttributes,
|
2016-06-26 16:57:33 +08:00
|
|
|
})
|
|
|
|
.then((localMessageAttributes) => (
|
2016-06-24 02:20:47 +08:00
|
|
|
Promise.props({
|
|
|
|
upserts: this._createAndUpdateMessages(remoteUIDAttributes, localMessageAttributes),
|
|
|
|
deletes: this._removeDeletedMessages(remoteUIDAttributes, localMessageAttributes),
|
|
|
|
})
|
2016-06-26 16:57:33 +08:00
|
|
|
))
|
|
|
|
.then(() => {
|
|
|
|
console.log(` - finished fetching changes to messages ${range}`);
|
2016-06-24 02:20:47 +08:00
|
|
|
return this.updateCategorySyncState({
|
|
|
|
highestmodseq: this._box.highestmodseq,
|
|
|
|
timeDeepScan: Date.now(),
|
|
|
|
timeShallowScan: Date.now(),
|
2016-06-26 16:57:33 +08:00
|
|
|
})
|
2016-06-24 02:20:47 +08:00
|
|
|
})
|
2016-06-28 07:05:31 +08:00
|
|
|
});
|
2016-06-24 02:20:47 +08:00
|
|
|
}
|
|
|
|
|
2016-06-21 05:44:02 +08:00
|
|
|
_fetchChangesToMessages() {
|
2016-06-24 02:20:47 +08:00
|
|
|
const {highestmodseq} = this._category.syncState;
|
2016-06-22 05:58:20 +08:00
|
|
|
const nextHighestmodseq = this._box.highestmodseq;
|
2016-06-24 02:20:47 +08:00
|
|
|
const range = `${this._getLowerBoundUID(this._options.limit)}:*`;
|
2016-06-21 05:44:02 +08:00
|
|
|
|
2016-06-21 08:33:23 +08:00
|
|
|
console.log(` - fetching changes to messages ${range}`)
|
2016-06-21 05:44:02 +08:00
|
|
|
|
2016-06-24 02:20:47 +08:00
|
|
|
if (this._shouldRunDeepScan()) {
|
|
|
|
return this._runDeepScan(range)
|
2016-06-22 05:58:20 +08:00
|
|
|
}
|
2016-06-21 05:44:02 +08:00
|
|
|
|
2016-06-22 05:58:20 +08:00
|
|
|
let shallowFetch = null;
|
|
|
|
if (this._imap.serverSupports(Capabilities.Condstore)) {
|
|
|
|
if (nextHighestmodseq === highestmodseq) {
|
|
|
|
console.log(" --- highestmodseq matches, nothing more to fetch")
|
|
|
|
return Promise.resolve();
|
|
|
|
}
|
2016-06-26 16:57:33 +08:00
|
|
|
shallowFetch = this._box.fetchUIDAttributes(range, {changedsince: highestmodseq});
|
2016-06-22 05:58:20 +08:00
|
|
|
} else {
|
2016-06-26 16:57:33 +08:00
|
|
|
shallowFetch = this._box.fetchUIDAttributes(`${this._getLowerBoundUID(1000)}:*`);
|
2016-06-22 05:58:20 +08:00
|
|
|
}
|
2016-06-21 05:44:02 +08:00
|
|
|
|
2016-06-26 16:57:33 +08:00
|
|
|
return shallowFetch
|
|
|
|
.then((remoteUIDAttributes) => (
|
2016-06-24 02:20:47 +08:00
|
|
|
this._db.Message.findAll({
|
2016-06-22 05:58:20 +08:00
|
|
|
where: {CategoryId: this._category.id},
|
|
|
|
attributes: MessageFlagAttributes,
|
2016-06-26 16:57:33 +08:00
|
|
|
})
|
|
|
|
.then((localMessageAttributes) => (
|
2016-06-22 05:58:20 +08:00
|
|
|
this._createAndUpdateMessages(remoteUIDAttributes, localMessageAttributes)
|
2016-06-26 16:57:33 +08:00
|
|
|
))
|
|
|
|
.then(() => {
|
|
|
|
console.log(` - finished fetching changes to messages ${range}`);
|
2016-06-22 08:51:24 +08:00
|
|
|
return this.updateCategorySyncState({
|
2016-06-22 05:58:20 +08:00
|
|
|
highestmodseq: nextHighestmodseq,
|
|
|
|
timeShallowScan: Date.now(),
|
2016-06-26 16:57:33 +08:00
|
|
|
})
|
2016-06-22 05:58:20 +08:00
|
|
|
})
|
2016-06-26 16:57:33 +08:00
|
|
|
))
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
|
|
|
|
2016-06-22 08:51:24 +08:00
|
|
|
updateCategorySyncState(newState) {
|
|
|
|
if (_.isMatch(this._category.syncState, newState)) {
|
|
|
|
return Promise.resolve();
|
|
|
|
}
|
|
|
|
this._category.syncState = Object.assign(this._category.syncState, newState);
|
|
|
|
return this._category.save();
|
|
|
|
}
|
|
|
|
|
2016-06-21 05:44:02 +08:00
|
|
|
run(db, imap) {
|
|
|
|
this._db = db;
|
|
|
|
this._imap = imap;
|
|
|
|
|
2016-06-21 08:33:23 +08:00
|
|
|
return this._openMailboxAndEnsureValidity()
|
2016-06-26 16:57:33 +08:00
|
|
|
.then((box) => {
|
|
|
|
this._box = box
|
|
|
|
return this._fetchUnseenMessages()
|
|
|
|
.then(() => this._fetchChangesToMessages())
|
|
|
|
})
|
2016-06-21 05:44:02 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-06-24 02:20:47 +08:00
|
|
|
module.exports = FetchMessagesInCategory;
|