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