VulpesOS/gaia/apps/email/js/ext/pop3/sync.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

662 lines
25 KiB
JavaScript
Executable file

define(['logic', '../util', 'module', 'require', 'exports',
'../mailchew', '../syncbase', '../date', '../jobmixins',
'../allback', './pop3'],
function(logic, util, module, require, exports,
mailchew, sync, date, jobmixins,
allback, pop3) {
var PASTWARDS = 1;
/**
* Manage the synchronization process for POP3 accounts. In IMAP and
* ActiveSync, the work of this class is split in two (a `folderConn`
* and syncer), but since POP3 has no concept of folders, the syncer
* manages everything itself.
*
* This class still gets created for each folder for compatibiliy with
* IMAP/ActiveSync, but we fast-path out of sync operations if the
* folder we're looking at isn't the inbox.
*/
function Pop3FolderSyncer(account, storage) {
this.account = account;
this.storage = storage;
logic.defineScope(this, 'Pop3FolderSyncer', {
accountId: account.id,
folderId: storage.folderId
});
// Only sync folders if this is the inbox. Other folders are client-side only.
this.isInbox = (storage.folderMeta.type === 'inbox');
}
exports.Pop3FolderSyncer = Pop3FolderSyncer;
/**
* Wrap a function with connection handling, as follows:
* - If a successful connection can be established, fn gets called with
* a connection and the rest of the arguments. The argument at index
* cbIndex is wrapped to automatically call the connection `done`
* callback.
* - If connection fails, the argument at index cbIndex is called with
* the connection error.
*
* @param {boolean} getNew If a fresh connection should always be made.
* @param {int} cbIndex Index of the parent function's callback in args
* @param {string} whyLabel Description for why we need the connection
*/
function lazyWithConnection(getNew, cbIndex, whyLabel, fn) {
return function pop3LazyWithConnection() {
var args = Array.slice(arguments);
require([], function () {
var next = function() {
// Only the inbox actually needs a connection. Using the
// connection in a non-inbox folder is an error.
if (!this.isInbox) {
fn.apply(this, [null].concat(args));
return;
}
this.account.withConnection(function (err, conn, done) {
var callback = args[cbIndex];
if (err) {
callback && callback(err);
} else {
args[cbIndex] = function lazyDone(err) {
done();
callback && callback(err);
};
fn.apply(this, [conn].concat(args));
}
}.bind(this), whyLabel);
}.bind(this);
// if we require a fresh connection, close out the old one first.
if (getNew && this.account._conn &&
this.account._conn.state !== 'disconnected') {
this.account._conn.quit(next);
} else {
next();
}
}.bind(this));
};
};
Pop3FolderSyncer.prototype = {
syncable: true,
get canGrowSync() {
// Only the inbox can be grown in POP3.
return this.isInbox;
},
/**
* Given a list of messages, download snippets for those that don't
* already have snippets. You need to pass an options argument so we
* only download a snippet. If you don't do that, you are doing
* something wrong. downloadBodyReps is the one that is for full
* body part/message downloading. XXX rename this family of methods.
*/
downloadBodies: lazyWithConnection(/* getNew = */ false, /* cbIndex = */ 2,
/* whyLabel = */ 'downloadBodies',
function(conn, headers, options, callback) {
var latch = allback.latch();
var storage = this.storage;
for (var i = 0; i < headers.length; i++) {
if (headers[i] && headers[i].snippet == null) {
this.downloadBodyReps(headers[i], options, latch.defer(i));
}
}
latch.then(function(results) {
var err = null; // pull out the first error, if it exists
for (var k in results) {
err = results[k][0];
}
callback(err, headers.length);
});
}),
/**
* Download the full body of a message. POP3 does not distinguish
* between message bodies and attachments, so we must retrieve them
* all in one go.
*/
downloadBodyReps: lazyWithConnection(/* getNew = */ false, /* cbIndex = */ 2,
/* whyLabel = */ 'downloadBodyReps',
function(conn, header, options, callback) {
if (options instanceof Function) {
callback = options;
options = {};
}
console.log('POP3: Downloading bodyReps for UIDL ' + header.srvid);
conn.downloadMessageByUidl(header.srvid, function(err, message) {
if (err) { callback(err); return; }
// Don't overwrite the header, because it contains useful
// identifiers like `suid` and things we want. Plus, with POP3,
// the server's headers will always be the same.
// However, we do need header.bytesToDownloadForBodyDisplay:
header.bytesToDownloadForBodyDisplay =
message.header.bytesToDownloadForBodyDisplay;
console.log('POP3: Storing message ' + header.srvid +
' with ' + header.bytesToDownloadForBodyDisplay +
' bytesToDownload.');
// Force a flush if there were any attachments so that any memory-backed
// Blobs get replaced with their post-save disk-backed equivalent so they
// can be garbage collected.
var flush = message.bodyInfo.attachments.length > 0;
this.storeMessage(header, message.bodyInfo, { flush: flush }, function() {
callback && callback(null, message.bodyInfo, flush);
});
}.bind(this));
}),
downloadMessageAttachments: function(uid, partInfos, callback, progress) {
// We already retrieved the attachments in downloadBodyReps, so
// this function should never be invoked (because callers would
// have seen that all relevant partInfos have set `isDownloaded`
// to true). Either way, there's nothing to do here.
console.log('POP3: ERROR: downloadMessageAttachments called and ' +
'POP3 shouldn\'t do that.');
callback(null, null);
},
/**
* Store a message. Depending on whether or not we've seen the
* message before, we'll either add it as a new message in storage
* or update the existing one.
*
* Our current POP3 implementation does not automatically delete
* messages from the server when they've been fetched, so we need to
* track which messages we've downloaded already and which ones are
* new. Unfortunately, this means that our sync with the server will
* take progressively longer as the server accumulates more messages
* in its store.
*
* Some servers might potentially "window" messages, such that the
* oldest messages in the message list might just drop off the
* server's list. If so, this code doesn't change; new messages will
* continue to be newly stored, and old messages will still be
* known.
*
* @param {HeaderInfo} header Message header.
* @param {BodyInfo} bodyInfo Body information, reps, etc.
* @param {Object} options
* @param {Boolean} options.flush Force a flush so the message gets reloaded,
* replacing memory-backed Blobs with
* disk-backed ones?
* @param {function()} callback
*/
storeMessage: function(header, bodyInfo, options, callback) {
callback = callback || function() {};
var event = {
changeDetails: {}
};
var knownId = this.getMessageIdForUidl(header.srvid);
if (header.id == null) { // might be zero.
if (knownId == null) {
header.id = this.storage._issueNewHeaderId();
} else {
header.id = knownId;
}
header.suid = this.storage.folderId + '/' + header.id;
header.guid = header.guid || header.srvid;
}
// Save all included attachments before actually storing the
// message. Downloaded attachments must be converted from a blob
// to a file on disk.
var latch = allback.latch();
var self = this;
for (var i = 0; i < bodyInfo.attachments.length; i++) {
var att = bodyInfo.attachments[i];
if (att.file instanceof Blob) {
// We want to save attachments to device storage (sdcard),
// rather than IndexedDB, for now. It's a v3 thing to use IndexedDB
// as a cache.
console.log('Saving attachment', att.file);
// Always register all POP3 downloads with the download manager since
// the user didn't have to explicitly trigger download for each
// attachment.
var registerDownload = true;
jobmixins.saveToDeviceStorage(
self, att.file, 'sdcard', registerDownload, att.name, att,
latch.defer());
// When saveToDeviceStorage completes, att.file will
// be a reference to the file on the sdcard.
}
}
latch.then(function() {
// Once the attachments have been downloaded, we can store the
// message. Here, we wait to call back from storeMessage() until
// we've saved _both_ the header and body.
latch = allback.latch();
if (knownId == null) {
self.storeMessageUidlForMessageId(header.srvid, header.id);
self.storage.addMessageHeader(header, bodyInfo, latch.defer());
self.storage.addMessageBody(header, bodyInfo, latch.defer());
} else {
self.storage.updateMessageHeader(
header.date, header.id, true, header, bodyInfo, latch.defer());
event.changeDetails.attachments = range(bodyInfo.attachments.length);
event.changeDetails.bodyReps = range(bodyInfo.bodyReps.length);
var updateOptions = {};
if (options.flush) {
updateOptions.flushBecause = 'blobs';
}
self.storage.updateMessageBody(
header, bodyInfo, updateOptions, event, latch.defer());
}
latch.then(function() {
callback(null, bodyInfo);
});
});
},
/**
* Return the folderMeta for the INBOX, upon which we store the
* uidlMap and overflowMap. Cache it for performance, since this
* function gets invoked frequently.
*/
get inboxMeta() {
// Override this getter to provide direct access in the future.
return (this.inboxMeta = this.account.getFolderMetaForFolderId(
this.account.getFirstFolderWithType('inbox').id));
},
/**
* Retrieve the message's id (header.id) given a server's UIDL.
*
* CAUTION: Zero is a valid message ID. I made the mistake of doing
* boolean comparisons on header IDs and that is a BAD IDEA. <3
* Hence the `== null` checks in a few places in this file.
*/
getMessageIdForUidl: function(uidl) {
if (uidl == null) {
return null;
}
this.inboxMeta.uidlMap = this.inboxMeta.uidlMap || {};
return this.inboxMeta.uidlMap[uidl];
},
/**
* Store the given message UIDL so that we know it has already been
* downloaded. If the message was previously marked as overflow,
* remove it from the overflow map because we know about it now.
*/
storeMessageUidlForMessageId: function(uidl, headerId) {
this.inboxMeta.uidlMap = this.inboxMeta.uidlMap || {};
this.inboxMeta.uidlMap[uidl] = headerId;
if (this.inboxMeta.overflowMap) {
delete this.inboxMeta.overflowMap[uidl];
}
},
/**
* Mark the given message UIDL as being an "overflow message"; that
* is, it was NOT downloaded and should be made available to
* download during a "download more messages..." operation.
*
* This data is stored in INBOX's folderMeta like so:
*
* overflowMap: {
* "(message uidl)": { size: 0 },
* ...
* }
*/
storeOverflowMessageUidl: function(uidl, size) {
this.inboxMeta.overflowMap = this.inboxMeta.overflowMap || {};
this.inboxMeta.overflowMap[uidl] = { size: size };
},
/**
* Return true if there are overflow messages. (If so, we're NOT
* synced to the dawn of time.)
*/
hasOverflowMessages: function() {
if (!this.inboxMeta.overflowMap) { return false; }
for (var key in this.inboxMeta.overflowMap) {
return true; // if there's even a single key, we have some!
}
return false;
},
/**
* Return whether or not the given UIDL is in the overflow map.
*/
isUidlInOverflowMap: function(uidl) {
if (!this.inboxMeta.overflowMap) { return false; }
return !!this.inboxMeta.overflowMap[uidl];
},
/**
* Sync the inbox for the first time. Since we set `ignoreHeaders`
* to true, we'll notify mail slices to update after the entire sync
* completes, so that all messages show up at once rather than one
* at a time.
*/
initialSync: function(slice, initialDays, syncCb, doneCb, progressCb) {
syncCb('sync', true /* ignoreHeaders */);
this.sync('initial', slice, doneCb, progressCb);
},
/**
* Sync the inbox for a refresh. This is the same as initialSync for
* POP3, except that we notify slices immediately upon receiving
* each new message individually.
*/
refreshSync: function(
slice, dir, startTS, endTS, origStartTS, doneCb, progressCb) {
this.sync('refresh', slice, doneCb, progressCb);
},
/**
* The unit tests issue "delete on server but not locally" commands.
* In order to mimic operations where we modify non-INBOX folders on
* the server and expect to learn about them from the client on
* sync, we queue up "server-only" modifications and execute them
* upon sync. This allows us to reuse much of the existing tests for
* certain folder operations, and becomes a no-op in production.
*
* @return {Boolean} true if a save is needed because we're actually doing
* something.
*/
_performTestAdditionsAndDeletions: function(cb) {
var meta = this.storage.folderMeta;
var numAdds = 0;
var latch = allback.latch();
var saveNeeded = false;
if (meta._TEST_pendingHeaderDeletes) {
meta._TEST_pendingHeaderDeletes.forEach(function(namer) {
saveNeeded = true;
this.storage.deleteMessageHeaderAndBody(namer.suid, namer.date,
latch.defer());
}, this);
meta._TEST_pendingHeaderDeletes = null;
}
if (meta._TEST_pendingAdds) {
meta._TEST_pendingAdds.forEach(function(msg) {
saveNeeded = true;
this.storeMessage(msg.header, msg.bodyInfo, {}, latch.defer());
}, this);
meta._TEST_pendingAdds = null;
}
latch.then(function(results) { cb(); });
return saveNeeded;
},
/**
* If we have overflow messages, fetch them here.
*/
growSync: function(slice, growthDirection, anchorTS, syncStepDays,
doneCallback, progressCallback) {
if (growthDirection !== PASTWARDS || !this.hasOverflowMessages()) {
return false;
}
// For simplicity, we ignore anchorTS and syncStepDays, because
// POP3's limitations make it difficult to infer anything about
// the messages we're going to download now. All we can do here is
// download another batch of overflow messages.
this.sync('grow', slice, doneCallback, progressCallback);
return true;
},
allConsumersDead: function() {
// Nothing to do here.
},
shutdown: function() {
// Nothing to do here either.
},
/**
* Pull down new headers from the server, attempting to fetch
* snippets for the messages.
*
* Pop3Client (in pop3.js) contains the variables used to determine
* how much of each message to fetch. Since POP3 only lets us
* download a certain number of _lines_ from the message, Pop3Client
* selects an appropriate snippet size (say, 4KB) and attempts to
* fetch approximately that much data for each message. That value
* is/should be high enough that we get snippets for nearly all
* messages, unless a message is particularly strange.
*
* Additionally, we don't delete messages from the server. This
* means that when we attempt to list messages, we'll see new
* messages along with messages we've seen before. To ensure we only
* retrieve messages we don't know about, we keep track of message
* unique IDs (UIDLs) and only download new messages.
*
* OVERFLOW MESSAGE HANDLING:
*
* We don't want to overwhelm a sync with a ridiculous number of
* messages if the spool has a lot of new messagse. Instead of
* blindly downloading all headers right away, we store excess
* "overflow" messages for future "grow" syncs, e.g. when the user
* clicks "Get More Messages" in the message list.
*
* This works as follows:
*
* If we're syncing normally, mark any excess messages as overflow
* messages and don't download them. This is handled largely by
* Pop3Client by the maxMessages option to listMessages(). We ignore
* any messages already marked as overflow for the purposes of the
* sync filter.
*
* If this is a grow sync, i.e. we want to download some overflow
* messages, we set the download filter to _only_ include overflow
* UIDLs. We may still have _more_ overflow messages, but that's
* okay, because they'll just be stored in the overflowMap like
* normal, for a future "grow" sync. Any messages we do download are
* marked as stored and removed from the overflowMap (in
* `this.storeMessageUidlForMessageId`).
*/
sync: lazyWithConnection(/* getNew = */ true, /* cbIndex = */ 2,
/* whyLabel = */ 'sync',
function(conn, syncType, slice, realDoneCallback, progressCallback) {
// if we could not establish a connection, abort the sync.
var self = this;
logic(self, 'sync:begin', { syncType: syncType });
// Avoid invoking realDoneCallback multiple times. Cleanup when we switch
// sync to promises/tasks.
var doneFired = false;
var doneCallback = function(err) {
if (doneFired) {
logic(self, 'sync:duplicateDone', { syncType: syncType, err: err });
return;
}
logic(self, 'sync:end', { syncType: syncType, err: err });
doneFired = true;
// coerce the rich error object to a string error code; currently
// refreshSlice only likes 'unknown' and 'aborted' so just run with
// unknown.
realDoneCallback(err ? 'unknown' : null);
};
// Only fetch info for messages we don't already know about.
var filterFunc;
if (syncType !== 'grow') {
// In a regular sync, download any message that we don't know
// about that isn't in the overflow map.
filterFunc = function(uidl) {
return self.getMessageIdForUidl(uidl) == null && // might be 0
!self.isUidlInOverflowMap(uidl);
};
} else /* (syncType === 'grow') */ {
// In a 'grow' sync, ONLY download overflow messages.
filterFunc = this.isUidlInOverflowMap.bind(this);
}
var bytesStored = 0;
var numMessagesSynced = 0;
var latch = allback.latch();
// We only want to trigger a save if work is actually being done. This is
// ugly/complicated because in order to let POP3 use the existing IMAP tests
// that did things in other folders, a test-only bypass route was created
// that has us actually add the messages
var saveNeeded;
if (!this.isInbox) {
slice.desiredHeaders = (this._TEST_pendingAdds &&
this._TEST_pendingAdds.length);
saveNeeded = this._performTestAdditionsAndDeletions(latch.defer());
} else {
saveNeeded = true;
logic(this, 'sync_begin');
var fetchDoneCb = latch.defer();
var closeExpected = false;
// register for a close notification so if disaster recovery closes the
// connection we still get a chance to report the error without breaking
// sync. This is the lowest priority onclose handler so all the other
// more specific error handlers will get a chance to fire. However, some
// like to defer to future turns of the event loop so we we use setTimeout
// to defer through at least two turns of the event loop.
conn.onclose = function() {
if (closeExpected) {
return;
}
closeExpected = true;
// see the above. This is horrible but we hate POP3 and these error
// handling cases are edge-casey and this actually does improve our test
// coverage. (test_pop3_dead_connection.js's first two test clauses
// were written before I added this onclose handler.)
window.setTimeout(function() {
window.setTimeout(function() {
doneCallback('closed');
}, 0);
}, 0);
};
// Fetch messages, ensuring that we don't actually store them all in
// memory so as not to burden memory unnecessarily.
conn.listMessages({
filter: filterFunc,
checkpointInterval: sync.POP3_SAVE_STATE_EVERY_N_MESSAGES,
maxMessages: sync.POP3_MAX_MESSAGES_PER_SYNC,
checkpoint: function(next) {
// Every N messages, wait for everything to be stored to
// disk and saved in the database. Then proceed.
this.account.__checkpointSyncCompleted(next, 'syncBatch');
}.bind(this),
progress: function fetchProgress(evt) {
// Store each message as it is retrieved.
var totalBytes = evt.totalBytes;
var message = evt.message;
var messageCb = latch.defer();
this.storeMessage(message.header, message.bodyInfo, {}, function() {
bytesStored += evt.size;
numMessagesSynced++;
progressCallback(0.1 + 0.7 * bytesStored / totalBytes);
messageCb();
});
}.bind(this),
}, function fetchDone(err, numSynced, overflowMessages) {
// Upon downloading all of the messages, we MUST issue a QUIT
// command. This will tear down the connection, however if we
// don't, we will never receive notifications of new messages.
// If we deleted any messages on the server (which we don't),
// the QUIT command is what would actually cause those to be
// persisted. In the future, when we support server-side
// deletion, we should ensure that this QUIT does not
// inadvertently commit unintended deletions.
closeExpected = true;
conn.quit();
if (err) {
doneCallback(err);
return;
}
// If there were excess messages, mark them for later download.
if (overflowMessages.length) {
overflowMessages.forEach(function(message) {
this.storeOverflowMessageUidl(message.uidl, message.size);
}, this);
logic(this, 'overflowMessages', { count: overflowMessages.length });
}
// When all of the messages have been persisted to disk, indicate
// that we've successfully synced. Refresh our view of the world.
fetchDoneCb();
}.bind(this));
}
latch.then((function onSyncDone() {
// Because POP3 has no concept of syncing discrete time ranges,
// we have to trick the storage into marking everything synced
// _except_ the dawn of time. This has to be slightly later than
// a value that would be interpreted as the dawn of time -- in
// this case, it has to be one day plus one. Ideally, this
// should be abstracted a little better; it's mostly IMAP that
// needs more involved logic.
this.storage.markSyncRange(
sync.OLDEST_SYNC_DATE + date.DAY_MILLIS + 1,
date.NOW(), 'XXX', date.NOW());
if (!this.hasOverflowMessages()) {
this.storage.markSyncedToDawnOfTime();
}
if (this.isInbox) {
logic(this, 'sync_end');
}
// Don't notify completion until the save completes, if relevant.
if (saveNeeded) {
this.account.__checkpointSyncCompleted(doDoneStuff, 'syncComplete');
} else {
doDoneStuff();
}
}).bind(this));
var doDoneStuff = function() {
if (syncType === 'initial') {
// If it's the first time we've synced, we've set
// ignoreHeaders to true, which means that slices don't know
// about new messages. We'll reset ignoreHeaders to false
// here, and then instruct the database to load messages
// again.
//
// We're waiting for the database to settle. Since POP3
// doesn't guarantee message ordering (in terms of listing
// messages in your maildrop), if we just blindly updated the
// current slice, the UI might frantically update as new
// messages come in. So for the initial sync, just batch them
// all in.
this.storage._curSyncSlice.ignoreHeaders = false;
this.storage._curSyncSlice.waitingOnData = 'db';
this.storage.getMessagesInImapDateRange(
sync.OLDEST_SYNC_DATE, null,
sync.INITIAL_FILL_SIZE, sync.INITIAL_FILL_SIZE,
// Don't trigger a refresh; we just synced. Accordingly,
// releaseMutex can be null.
this.storage.onFetchDBHeaders.bind(
this.storage, this.storage._curSyncSlice,
false, doneCallback, null));
} else {
doneCallback(null);
}
}.bind(this);
}),
};
/** Return an array with the integers [0, end). */
function range(end) {
var ret = [];
for (var i = 0; i < end; i++) {
ret.push(i);
}
return ret;
}
}); // end define