/** * Presents a message-centric view of a slice of time from IMAP search results. * * == Use-case assumptions * * - We are backing a UI showing a list of time-ordered messages. This can be * the contents of a folder, on-server search results, or the * (server-facilitated) list of messages in a conversation. * - We want to fetch more messages as the user scrolls so that the entire * contents of the folder/search results list are available. * - We want to show the message as soon as possible. So we can show a message * in the list before we have its snippet. However, we do want the * bodystructure before we show it so we can accurately know if it has * attachments. * - We want to update the state of the messages in real-time as we hear about * changes from the server, such as another client starring a message or * marking the message read. * - We will synchronize some folders with either a time and/or message count * threshold. * - We want mutations made locally to appear as if they are applied * immediately, even if we are operating offline. * * == Efficiency desires * * - Avoid redundant network traffic by caching our results using IndexedDB. * - Keep the I/O burden and overhead low from caching/sync. We know our * primary IndexedDB implementation is backed by SQLite with full * transaction commits corresponding to IndexedDB transaction commits. * We also know that all IndexedDB work gets marshaled to another thread. * Since the server is the final word in state, except for mutations we * trigger, we don't need to be aggressive about persisting state. * Accordingly, let's persist our data in big blocks only on major * transitions (folder change) or when our memory usage is getting high. * (If we were using LevelDB, large writes would probably be less * desirable.) * * == Of slices, folders, and gmail * * It would be silly for a slice that is for browsing the folder unfiltered and * a slice that is a result of a search to act as if they were dealing with * different messages. Similarly, it would be silly in gmail for us to fetch * a message that we know is the same message across multiple (labels as) * folders. So we abstract away the storage details to `FolderStorage`. * * == Latency, offline access, and IMAP * * The fundamental trade-off is between delaying showing things in the UI and * showing them and then having a bunch of stuff happen a split-second later. * (Messages appearing, disappearing, having their status change, etc.) * **/ define(function(require, exports, module) { var logic = require('logic'); var $util = require('./util'); var $a64 = require('./a64'); var $allback = require('./allback'); var $date = require('./date'); var $sync = require('./syncbase'); var bsearchForInsert = $util.bsearchForInsert, bsearchMaybeExists = $util.bsearchMaybeExists, cmpHeaderYoungToOld = $util.cmpHeaderYoungToOld, allbackMaker = $allback.allbackMaker, BEFORE = $date.BEFORE, ON_OR_BEFORE = $date.ON_OR_BEFORE, SINCE = $date.SINCE, STRICTLY_AFTER = $date.STRICTLY_AFTER, IN_BS_DATE_RANGE = $date.IN_BS_DATE_RANGE, HOUR_MILLIS = $date.HOUR_MILLIS, DAY_MILLIS = $date.DAY_MILLIS, NOW = $date.NOW, quantizeDate = $date.quantizeDate, quantizeDateUp = $date.quantizeDateUp; var PASTWARDS = 1, FUTUREWARDS = -1; // What do we think the post-snappy compression overhead of the structured clone // persistence rep will be for various things? These are total guesses right // now. Keep in mind we do want the pre-compression size of the data in all // cases and we just hope it will compress a bit. For the attributes we are // including the attribute name as well as any fixed-overhead for its payload, // especially numbers which may or may not be zig-zag encoded/etc. var OBJ_OVERHEAD_EST = 2, STR_ATTR_OVERHEAD_EST = 5, NUM_ATTR_OVERHEAD_EST = 10, LIST_ATTR_OVERHEAD_EST = 4, NULL_ATTR_OVERHEAD_EST = 2, LIST_OVERHEAD_EST = 4, NUM_OVERHEAD_EST = 8, STR_OVERHEAD_EST = 4; /** * Intersects two objects each defining tupled ranges of the type * { startTS, startUID, endTS, endUID }, like block infos and mail slices. * This is exported for unit testing purposes and because no state is closed * over. */ var tupleRangeIntersectsTupleRange = exports.tupleRangeIntersectsTupleRange = function tupleRangeIntersectsTupleRange(a, b) { if (BEFORE(a.endTS, b.startTS) || STRICTLY_AFTER(a.startTS, b.endTS)) return false; if ((a.endTS === b.startTS && a.endUID < b.startUID) || (a.startTS === b.endTS && a.startTS > b.endUID)) return false; return true; }; /** * How much progress in the range [0.0, 1.0] should we report for just having * started the synchronization process? The idea is that by having this be * greater than 0, our progress bar indicates that we are doing something or * at least know we should be doing something. */ var SYNC_START_MINIMUM_PROGRESS = 0.02; /** * Book-keeping and limited agency for the slices. * * === Batching === * Headers are removed, added, or modified using the onHeader* methods. * The updates are sent to 'SliceBridgeProxy' which batches updates and * puts them on the event loop. We batch so that we can minimize the number of * reflows and painting on the DOM side. This also enables us to batch data * received in network packets around the smae time without having to handle it in * each protocol's logic. * * Currently, we only batch updates that are done between 'now' and the next time * a zeroTimeout can fire on the event loop. In order to keep the UI responsive, * We force flushes if we have more than 5 pending slices to send. */ function MailSlice(bridgeHandle, storage) { this._bridgeHandle = bridgeHandle; bridgeHandle.__listener = this; this._storage = storage; logic.defineScope(this, 'MailSlice', { bridgeHandle: bridgeHandle._handle }); // The time range of the headers we are looking at right now. this.startTS = null; this.startUID = null; // If the end values line up with the most recent message known about for this // folder, then we will grow to encompass more recent messages. this.endTS = null; this.endUID = null; /** * A string value for hypothetical debugging purposes, but which is coerced * to a Boolean value for some of our slice notifications as both the * userRequested/moreExpected values, although they aren't super important. */ this.waitingOnData = false; /** * If true, don't add any headers. This is used by ActiveSync during its * synchronization step to wait until all headers have been retrieved and * then the slice is populated from the database. After this initial sync, * ignoreHeaders is set to false so that updates and (hopefully small * numbers of) additions/removals can be observed. */ this.ignoreHeaders = false; /** * @listof[HeaderInfo] */ this.headers = []; this.desiredHeaders = $sync.INITIAL_FILL_SIZE; this.headerCount = storage.headerCount; } exports.MailSlice = MailSlice; MailSlice.prototype = { /** * We are a folder-backed view-slice. */ type: 'folder', set atTop(val) { if (this._bridgeHandle) this._bridgeHandle.atTop = val; return val; }, set atBottom(val) { if (this._bridgeHandle) this._bridgeHandle.atBottom = val; return val; }, set userCanGrowUpwards(val) { if (this._bridgeHandle) this._bridgeHandle.userCanGrowUpwards = val; return val; }, set userCanGrowDownwards(val) { if (this._bridgeHandle) this._bridgeHandle.userCanGrowDownwards = val; return val; }, get headerCount() { if (this._bridgeHandle) { return this._bridgeHandle.headerCount; } return null; }, set headerCount(val) { if (this._bridgeHandle) this._bridgeHandle.headerCount = val; return val; }, _updateSliceFlags: function() { var flagHolder = this._bridgeHandle; flagHolder.atTop = this._storage.headerIsYoungestKnown(this.endTS, this.endUID); flagHolder.atBottom = this._storage.headerIsOldestKnown(this.startTS, this.startUID); if (flagHolder.atTop) flagHolder.userCanGrowUpwards = !this._storage.syncedToToday(); else flagHolder.userCanGrowUpwards = false; if (flagHolder.atBottom) flagHolder.userCanGrowDownwards = !this._storage.syncedToDawnOfTime(); else flagHolder.userCanGrowDownwards = false; }, /** * Reset the state of the slice, clearing out any known headers. */ reset: function() { if (!this._bridgeHandle) return; this.headerCount = this._storage.headerCount; if (this.headers.length) { this._bridgeHandle.sendSplice(0, this.headers.length, [], false, true); this.headers.splice(0, this.headers.length); this.startTS = null; this.startUID = null; this.endTS = null; this.endUID = null; } }, /** * Force an update of our current date range. */ refresh: function() { this._storage.refreshSlice(this); }, reqNoteRanges: function(firstIndex, firstSuid, lastIndex, lastSuid) { if (!this._bridgeHandle) return; var i; // - Fixup indices if required if (firstIndex >= this.headers.length || this.headers[firstIndex].suid !== firstSuid) { firstIndex = 0; // default to not splicing if it's gone for (i = 0; i < this.headers.length; i++) { if (this.headers[i].suid === firstSuid) { firstIndex = i; break; } } } if (lastIndex >= this.headers.length || this.headers[lastIndex].suid !== lastSuid) { for (i = this.headers.length - 1; i >= 0; i--) { if (this.headers[i].suid === lastSuid) { lastIndex = i; break; } } } // - Perform splices as required // (high before low to avoid index changes) if (lastIndex + 1 < this.headers.length) { this.atBottom = false; this.userCanGrowDownwards = false; var delCount = this.headers.length - lastIndex - 1; this.desiredHeaders -= delCount; this._bridgeHandle.sendSplice( lastIndex + 1, delCount, [], // This is expected; more coming if there's a low-end splice true, firstIndex > 0); this.headers.splice(lastIndex + 1, this.headers.length - lastIndex - 1); var lastHeader = this.headers[lastIndex]; this.startTS = lastHeader.date; this.startUID = lastHeader.id; } if (firstIndex > 0) { this.atTop = false; this.userCanGrowUpwards = false; this.desiredHeaders -= firstIndex; this._bridgeHandle.sendSplice(0, firstIndex, [], true, false); this.headers.splice(0, firstIndex); var firstHeader = this.headers[0]; this.endTS = firstHeader.date; this.endUID = firstHeader.id; } this._storage.sliceShrunk(this); }, reqGrow: function(dirMagnitude, userRequestsGrowth) { if (dirMagnitude === -1) dirMagnitude = -$sync.INITIAL_FILL_SIZE; else if (dirMagnitude === 1) dirMagnitude = $sync.INITIAL_FILL_SIZE; this._storage.growSlice(this, dirMagnitude, userRequestsGrowth); }, sendEmptyCompletion: function() { this.setStatus('synced', true, false); }, setStatus: function(status, requested, moreExpected, flushAccumulated, progress, newEmailCount) { if (!this._bridgeHandle) return; switch (status) { case 'synced': case 'syncfailed': this._updateSliceFlags(); break; } this._bridgeHandle.sendStatus(status, requested, moreExpected, progress, newEmailCount); }, /** * Update our sync progress with a value in the range [0.0, 1.0]. We leave * it up to the specific protocol to determine how it maps values. */ setSyncProgress: function(value) { if (!this._bridgeHandle) return; this._bridgeHandle.sendSyncProgress(value); }, /** * @args[ * @param[headers @listof[MailHeader]] * @param[insertAt @oneof[ * @case[-1]{ * Append to the end of the list * } * @case[Number]{ * Insert the headers at the given index. * } * ]] * @param[moreComing Boolean] * ] */ batchAppendHeaders: function(headers, insertAt, moreComing) { if (!this._bridgeHandle) return; logic(this, 'headersAppended', { headers: headers }); if (insertAt === -1) insertAt = this.headers.length; this.headers.splice.apply(this.headers, [insertAt, 0].concat(headers)); // XXX this can obviously be optimized to not be a loop for (var i = 0; i < headers.length; i++) { var header = headers[i]; if (this.startTS === null || BEFORE(header.date, this.startTS)) { this.startTS = header.date; this.startUID = header.id; } else if (header.date === this.startTS && header.id < this.startUID) { this.startUID = header.id; } if (this.endTS === null || STRICTLY_AFTER(header.date, this.endTS)) { this.endTS = header.date; this.endUID = header.id; } else if (header.date === this.endTS && header.id > this.endUID) { this.endUID = header.id; } } this._updateSliceFlags(); this._bridgeHandle.sendSplice(insertAt, 0, headers, true, moreComing); }, /** * Tell the slice about a header it should be interested in. This should * be unconditionally called by a sync populating this slice, or conditionally * called when the header is in the time-range of interest and a refresh, * cron-triggered sync, or IDLE/push tells us to do so. */ onHeaderAdded: function(header, body, syncDriven, messageIsNew) { if (!this._bridgeHandle) return; var idx = bsearchForInsert(this.headers, header, cmpHeaderYoungToOld); var hlen = this.headers.length; // Don't append the header if it would expand us beyond our requested // amount. Note that this does not guarantee that we won't end up with more // headers than originally planned; if we get told about headers earlier // than the last slot, we will insert them and grow without forcing a // removal of something else to offset. if (hlen >= this.desiredHeaders && idx === hlen) return; // If we are inserting (not at the end) then be sure to grow // the number of desired headers to be consistent with the number of headers // we have. if (hlen >= this.desiredHeaders) this.desiredHeaders++; if (this.startTS === null || BEFORE(header.date, this.startTS)) { this.startTS = header.date; this.startUID = header.id; } else if (header.date === this.startTS && header.id < this.startUID) { this.startUID = header.id; } if (this.endTS === null || STRICTLY_AFTER(header.date, this.endTS)) { this.endTS = header.date; this.endUID = header.id; } else if (header.date === this.endTS && header.id > this.endUID) { this.endUID = header.id; } logic(this, 'headerAdded', { index: idx, header: header }); this._bridgeHandle.sendSplice(idx, 0, [header], Boolean(this.waitingOnData), Boolean(this.waitingOnData)); this.headers.splice(idx, 0, header); }, /** * Tells the slice that a header it should know about has changed. (If * this is a search, it's okay for it not to know...) */ onHeaderModified: function(header, body) { if (!this._bridgeHandle) return; // this can only affect flags which will not affect ordering var idx = bsearchMaybeExists(this.headers, header, cmpHeaderYoungToOld); if (idx !== null) { // There is no identity invariant to ensure this is already true. this.headers[idx] = header; logic(this, 'headerModified', { index: idx, header: header }); this._bridgeHandle.sendUpdate([idx, header]); } }, /** * Tells the slice that a header it should know about has been removed. */ onHeaderRemoved: function(header) { if (!this._bridgeHandle) return; var idx = bsearchMaybeExists(this.headers, header, cmpHeaderYoungToOld); if (idx !== null) { logic(this, 'headerRemoved', { index: idx, header: header }); this._bridgeHandle.sendSplice(idx, 1, [], Boolean(this.waitingOnData), Boolean(this.waitingOnData)); this.headers.splice(idx, 1); // update time-ranges if required... if (header.date === this.endTS && header.id === this.endUID) { if (!this.headers.length) { this.endTS = null; this.endUID = null; } else { this.endTS = this.headers[0].date; this.endUID = this.headers[0].id; } } if (header.date === this.startTS && header.id === this.startUID) { if (!this.headers.length) { this.startTS = null; this.startUID = null; } else { var lastHeader = this.headers[this.headers.length - 1]; this.startTS = lastHeader.date; this.startUID = lastHeader.id; } } } }, die: function() { this._bridgeHandle = null; this.desiredHeaders = 0; this._storage.dyingSlice(this); }, get isDead() { return this._bridgeHandle === null; }, }; /** * Folder version history: * * v3: Unread count tracking fixed, so we need to re-run it. * * v2: Initial unread count tracking. Regrettably with bad maths. */ var FOLDER_DB_VERSION = exports.FOLDER_DB_VERSION = 3; /** * Per-folder message caching/storage; issues per-folder `MailSlice`s and keeps * them up-to-date. Access is mediated through the use of mutexes which must be * acquired for write access and are advisable for read access that requires * access to more than a single message. * * ## Naming and Ordering * * Messages in the folder are named and ordered by the tuple of the message's * received date and a "sufficiently unique identifier" (SUID) we allocate. * * The SUID is actually a concatenation of an autoincrementing per-folder 'id' * to our folder id, which in turn contains the account id. Internally, we only * care about the 'id' since the rest is constant for the folder. However, all * APIs layered above us need to deal in SUIDs since we will eventually have * `MailSlice` instances that aggregate the contents so it is important that the * extra information always be passed around. * * Because the SUID has no time component and for performance we want a total * ordering on the messages, messages are first ordered on their 'received' * date. For IMAP this is the message's INTERNALDATE. For ActiveSync this is * the email:DateReceived element. Accordingly, when performing a lookup, we * either need the exact date of the message or a reasonable bounded time range * in which it could fall (which should be a given for date range scans). * * ## Storage, Caching, Cache Flushing * * Storage is done using IndexedDB, with message header information and message * body information stored in separate blocks of information. See the * `maildb.js` file and `MailDB` class for more detailed information. * * Blocks are loaded from disk on demand and cached, although preferably hints * are received so we can pre-load information. Blocks are discarded from the * cache automatically when a mutex is released or when explicitly invoked by * the code currently holding the mutex. Code that can potentially cause a * large number of blocks to be loaded is responsible for periodically * triggering cache evictions and/or writing of dirty blocks to disk so that * cache evictions are possible. * * We avoid automatic cache eviction in order to avoid the class of complex bugs * that might arise. While well-written code should not run afoul of automatic * cache eviction were it to exist, buggy code happens. We can more reliably * detect potentially buggy code this way by simply reporting whenever the * number of loaded blocks exceeds some threshold. * * When evicting blocks from cache, we try and keep blocks around that contain * messages referenced by active `MailSlice` instances in order to avoid the * situation where we discard blocks just to reload them with the next user * action, and with added latency. * * If WeakMap were standardized, we would instead move blocks into a WeakMap, * but it's not, so we don't. * * ## Block Purging (IMAP) * * For account types like IMAP where we can incrementally grow the set of * messages we have synchronized from the server, our entire database is * effectively a cache of the server state. This is in contrast to ActiveSync * where we synchronize a fixed time-window of messages and so the exact set of * messages we should know about is well-defined and bounded. As a result, we * need to be able to purge old messages that the user no longer appears to * care about so that our disk usage does not grow without bound. * * We currently trigger block purging as the result of block growth in a folder. * Specifically * * Messages are discarded from storage when experiencing storage pressure. We * figure it's better to cache what we have until it's known useless (deleted * messages) or we definitely need the space for something else. * * ## Concurrency and I/O * * The logic in this class can operate synchronously as long as the relevant * header/body blocks are in-memory. For simplicity, we (asynchronously) defer * execution of calls that mutate state while loads are in-progress; callers * will not block. This simplifies our implementation and thinking about our * implementation without making life for our users much worse. * * Specifically, all UI requests for data will be serviced immediately if the * data is available. If the data is not available, the wait would have * happened anyways. Mutations will be enqueued, but are always speculatively * assumed to succeed by the UI anyways so when they are serviced is not * exceedingly important other than a burden on us to surface in the UI that * we still have some state to synchronize to the server so the user does * not power-off their phone quite yet. * * ## Types * * @typedef[AccuracyRangeInfo @dict[ * @key[endTS DateMS]{ * This value is exclusive in keeping with IMAP BEFORE semantics. * } * @key[startTS DateMS]{ * This value is inclusive in keeping with IMAP SINCE semantics. * } * @key[fullSync @dict[ * @key[highestModseq #:optional String]{ * The highest modseq for this range, if we have one. This would be the * value reported on folder entry, plus any maximization that occurs if we * utilized IDLE or some other mechanism to keep the range up-to-date. * On servers without highestmodseq, this will be null. * } * @key[updated DateMS]{ * What was our local timestamp the last time we synchronized this range? * This is speculative and probably just for debugging unless we have the * UI reflect that in offline mode it knows what it is showing you could * be fairly out of date. * } * } * ]]{ * Did we fully synchronize this time range (because of a date scan)? If * false, the implication is that we know about the messages in this range * because of some type of search. * } * ]]{ * Describes the provenance of the data we have for a given time range. * Tracked independently of the block data because there doesn't really seem * to be an upside to coupling them. * * This lets us know when we have sufficiently valid data to display messages * without needing to talk to the server, allows us to size checks for * new messages in time ranges, and should be a useful debugging aid. * } * @typedef[FolderBlockInfo @dict[ * @key[blockId BlockId]{ * The name of the block for storage access. * } * @key[startTS DateMS]{ * The timestamp of the last and therefore (possibly equally) oldest message * in this block. Forms the first part of a composite key with `startUID`. * } * @key[startUID UID]{ * The UID of the last and therefore (possibly equally) oldest message * in this block. Forms the second part of a composite key with `startTS`. * } * @key[endTS DateMS]{ * The timestamp of the first and therefore (possibly equally) newest * message in this block. Forms the first part of a composite key with * `endUID`. * } * @key[endUID UID]{ * The UID of the first and therefore (possibly equally) newest message * in this block. Forms the second part of a composite key with `endTS`. * } * @key[count Number]{ * The number of messages in this bucket. * } * @key[estSize Number]{ * The estimated size in bytes all of the messages in this bucket use. This * is to assist us in known when to split/merge blocks. * } * ]]{ * The directory entries for our `HeaderBlock` and `BodyBlock` instances. * Currently, these are always stored in memory since they are small and * there shouldn't be a tremendous number of them. * * These * } * @typedef[EmailAddress String] * @typedef[NameAddressPair @dict[ * @key[address EmailAddress] * @key[name String] * ]] * @typedef[HeaderInfo @dict[ * @key[id]{ * An id allocated by the back-end that names the message within the folder. * We use this instead of the server-issued UID because if we used the UID * for this purpose then we would still need to issue our own temporary * speculative id's for offline operations and would need to implement * renaming and it all gets complicated. * } * @key[srvid]{ * The server-issued UID for the folder, or 0 if the folder is an offline * header. * } * @key[suid]{ * Basically "account id/folder id/message id", although technically the * folder id includes the account id. * } * @key[guid String]{ * This is the message-id header value of the message. * } * @key[author NameAddressPair] * @key[date DateMS] * @key[flags @listof[String]] * @key[hasAttachments Boolean] * @key[subject String] * @key[snippet @oneof[ * @case[null]{ * We haven't tried to generate a snippet yet. * } * @case['']{ * We tried to generate a snippet, but got nothing useful. Note that we * may try and generate a snippet from a partial body fetch; this does not * indicate that we should avoid computing a better snippet. Whenever the * snippet is falsey and we have retrieved more body data, we should * always try and derive a snippet. * } * @case[String]{ * A non-empty string means we managed to produce some snippet data. It * is still appropriate to regenerate the snippet if more body data is * fetched since our snippet may be a fallback where we chose quoted text * instead of authored text, etc. * } * ]] * ]] * @typedef[HeaderBlock @dict[ * @key[ids @listof[ID]]{ * The issued-by-us-id's of the headers in the same order (not the IMAP * UID). This is intended as a fast parallel search mechanism. It can be * discarded if it doesn't prove useful. * * XXX We want to rename this to be "ids" in a refactoring pass in the * future. * } * @key[headers @listof[HeaderInfo]]{ * Headers in numerically decreasing time and issued-by-us-ID order. The * header at index 0 should correspond to the 'end' characteristics of the * blockInfo and the header at n-1 should correspond to the start * characteristics. * } * ]] * @typedef[AttachmentInfo @dict[ * @key[name String]{ * The filename of the attachment, if any. * } * @key[contentId String]{ * The content-id of the attachment if this is a related part for inline * display. * } * @key[type String]{ * The (full) mime-type of the attachment. * } * @key[part String]{ * The IMAP part number for fetching the attachment. * } * @key[encoding String]{ * The encoding of the attachment so we know how to decode it. * } * @key[sizeEstimate Number]{ * Estimated file size in bytes. Gets updated to be the correct size on * attachment download. * } * @key[file @oneof[ * @case[null]{ * The attachment has not been downloaded, the file size is an estimate. * } * @case[@list["device storage type" "file path"]{ * The DeviceStorage type (ex: pictures) and the path to the file within * device storage. * } * @case[HTMLBlob]{ * The Blob that contains the attachment. It can be thought of as a * handle/name to access the attachment. IndexedDB in Gecko stores the * blobs as (quota-tracked) files on the file-system rather than inline * with the record, to the attachments don't need to count against our * block size since they are not part of the direct I/O burden for the * block. * } * ]] * @key[charset @oneof[undefined String]]{ * The character set, for example "ISO-8859-1". If not specified, as is * likely for binary attachments, this should be null. * } * @key[textFormat @oneof[undefined String]]{ * The text format, for example, "flowed" for format=flowed. If not * specified, as is likely for binary attachments, this should be null. * } * ]] * @typedef[BodyInfo @dict[ * @key[date DateMS]{ * Redundantly stored date info for block splitting purposes. We pretty * much need this no matter what because our ordering is on the tuples of * dates and UIDs, so we could have trouble efficiently locating our header * from the body without this. * } * @key[size Number] * @key[to @listof[NameAddressPair]] * @key[cc @listof[NameAddressPair]] * @key[bcc @listof[NameAddressPair]] * @key[replyTo NameAddressPair] * @key[attachments @listof[AttachmentInfo]]{ * Proper attachments for explicit downloading. * } * @key[relatedParts @oneof[null @listof[AttachmentInfo]]]{ * Attachments for inline display in the contents of the (hopefully) * multipart/related message. * } * @key[references @oneof[null @listof[String]]]{ * The contents of the references header as a list of de-quoted ('<' and * '>' removed) message-id's. If there was no header, this is null. * } * @key[bodyReps @listof[@oneof[String Array]]]{ * This is a list where each two consecutive elements describe a body * representation. The even indices are the body rep types which are * either 'plain' or 'html'. The odd indices are the actual * representations. * * The representation for 'plain' values is a `quotechew.js` processed * body representation (which is itself a similar pair-wise list except * that the identifiers are packed integers). * * The body representation for 'html' values is an already sanitized and * already quote-normalized String representation that could be directly * fed into innerHTML safely if you were so inclined. See `htmlchew.js` * for more on that process. * } * ]]{ * Information on the message body that is only for full message display. * The to/cc/bcc information may get moved up to the header in the future, * but our driving UI doesn't need it right now. * } * @typedef[BodyBlock @dict[ * @key[ids @listof[ID]]{ * The issued-by-us id's of the messages; the order is parallel to the order * of `bodies.` * } * @key[bodies @dictof[ * @key["unique identifier" ID] * @value[BodyInfo] * ]] * ]] */ function FolderStorage(account, folderId, persistedFolderInfo, dbConn, FolderSyncer) { /** Our owning account. */ this._account = account; this._imapDb = dbConn; this.folderId = folderId; this.folderMeta = persistedFolderInfo.$meta; this._folderImpl = persistedFolderInfo.$impl; logic.defineScope(this, 'FolderStorage', { accountId: account.id, folderId: folderId }); /** * @listof[AccuracyRangeInfo]{ * Newest-to-oldest sorted list of accuracy range info structures that are * keyed by their IMAP-consistent startTS (inclusive) and endTS (exclusive) * on a per-day granularity. * } */ this._accuracyRanges = persistedFolderInfo.accuracy; /** * @listof[FolderBlockInfo]{ * Newest-to-oldest (numerically decreasing time and ID) sorted list of * header folder block infos. They are keyed by a composite key consisting * of messages' "date" and "id" fields. * } */ this._headerBlockInfos = persistedFolderInfo.headerBlocks; // Calculate total number of messages this.headerCount = 0; if (this._headerBlockInfos) { this._headerBlockInfos.forEach(function(headerBlockInfo) { this.headerCount += headerBlockInfo.count; }.bind(this)); } /** * @listof[FolderBlockInfo]{ * Newest-to-oldest (numerically decreasing time and ID) sorted list of * body folder block infos. They are keyed by a composite key consisting * of messages' "date" and "id" fields. * } */ this._bodyBlockInfos = persistedFolderInfo.bodyBlocks; /** * @oneof[null @dictof[ * @key[ServerID]{ * The "srvid" value of a header entry. * } * @value[BlockID]{ * The block the header is stored in. * } * ]] */ this._serverIdHeaderBlockMapping = persistedFolderInfo.serverIdHeaderBlockMapping; /** * @dictof[@key[BlockId] @value[HeaderBlock]]{ * In-memory cache of header blocks. * } */ this._headerBlocks = {}; /** * @listof[FolderBlockInfo]{ * The block infos of all the header blocks in `_headerBlocks`. Exists so * that we don't need to map blocks back to their block infos when we are * considering flushing things. This could also be used for most recently * loaded tracking. * } */ this._loadedHeaderBlockInfos = []; /** * @dictof[@key[BlockId] @value[BodyBlock]]{ * In-memory cache of body blocks. * } */ this._bodyBlocks = {}; /** * @listof[FolderBlockInfo]{ * The block infos of all the body blocks in `_bodyBlocks`. Exists so * that we don't need to map blocks back to their block infos when we are * considering flushing things. This could also be used for most recently * loaded tracking. * } */ this._loadedBodyBlockInfos = []; this._flushExcessTimeoutId = 0; this._bound_flushExcessOnTimeout = this._flushExcessOnTimeout.bind(this); this._bound_makeHeaderBlock = this._makeHeaderBlock.bind(this); this._bound_insertHeaderInBlock = this._insertHeaderInBlock.bind(this); this._bound_splitHeaderBlock = this._splitHeaderBlock.bind(this); this._bound_deleteHeaderFromBlock = this._deleteHeaderFromBlock.bind(this); this._bound_makeBodyBlock = this._makeBodyBlock.bind(this); this._bound_insertBodyInBlock = this._insertBodyInBlock.bind(this); this._bound_splitBodyBlock = this._splitBodyBlock.bind(this); this._bound_deleteBodyFromBlock = this._deleteBodyFromBlock.bind(this); /** * Has our internal state altered at all and will need to be persisted? */ this._dirty = false; /** @dictof[@key[BlockId] @value[HeaderBlock]] */ this._dirtyHeaderBlocks = {}; /** @dictof[@key[BlockId] @value[BodyBlock]] */ this._dirtyBodyBlocks = {}; /** * @listof[AggrBlockId] */ this._pendingLoads = []; /** * @dictof[ * @key[AggrBlockId] * @key[@listof[@func]] * ] */ this._pendingLoadListeners = {}; /** * @listof[@func[]]{ * A list of fully-bound functions to drain when the last pending load gets * loaded, at least until a new load goes pending. * } */ this._deferredCalls = []; /** * @listof[@dict[ * @key[name String]{ * A string describing the operation to be performed for debugging * purposes. This string must not include any user data. * } * @key[func @func[@args[callWhenDone]]]{ * The function to be invoked. * } * ]]{ * The list of mutexed call operations queued. The first entry is the * currently executing entry. * } */ this._mutexQueue = []; /** * Active view / search slices on this folder. */ this._slices = []; /** * The slice that is driving our current synchronization and wants to hear * about all header modifications/notes as they occur. This will be null * when performing a refresh sync. */ this._curSyncSlice = null; this._messagePurgeScheduled = false; this.folderSyncer = FolderSyncer && new FolderSyncer(account, this); } exports.FolderStorage = FolderStorage; /** * Return true if the given folder type is local-only (i.e. we will * not try to sync this folder with the server). * * @param {String} type * The type of the folderStorage, e.g. 'inbox' or 'localdrafts'. */ FolderStorage.isTypeLocalOnly = function(type) { if (typeof type !== 'string') { throw new Error('isTypeLocalOnly() expects a string, not ' + type); } return (type === 'outbox' || type === 'localdrafts'); } FolderStorage.prototype = { get hasActiveSlices() { return this._slices.length > 0; }, get isLocalOnly() { return FolderStorage.isTypeLocalOnly(this.folderMeta.type); }, /** * Reset all active slices. */ resetAndRefreshActiveSlices: function() { if (!this._slices.length) return; // This will splice out slices as we go, so work from the back to avoid // processing any slice more than once. (Shuffling of processed slices // will occur, but we don't care.) for (var i = this._slices.length - 1; i >= 0; i--) { var slice = this._slices[i]; slice.desiredHeaders = $sync.INITIAL_FILL_SIZE; slice.reset(); if (slice.type === 'folder') { this._resetAndResyncSlice(slice, true, null); } } }, /** * Called by our owning account to generate lists of dirty blocks to be * persisted to the database if we have any dirty blocks. * * We trigger a cache flush after clearing the set of dirty blocks because * this is the first time we can flush the no-longer-dirty blocks and this is * an acceptable/good time to clear the cache since we must not be in a mutex. */ generatePersistenceInfo: function() { if (!this._dirty) return null; var pinfo = { id: this.folderId, headerBlocks: this._dirtyHeaderBlocks, bodyBlocks: this._dirtyBodyBlocks, }; logic(this, 'generatePersistenceInfo', { info: pinfo }); this._dirtyHeaderBlocks = {}; this._dirtyBodyBlocks = {}; this._dirty = false; this.flushExcessCachedBlocks('persist'); // Generate a notification that something about this folder has probably // changed. If it was important enough to save us, then it's arguably // important enough to tell the front-end about it too. Plus the I/O // overhead is way more costly than throwing some minimal updates at the // front-end. // // (Previously all that could change was our last sync time and so // we only did this in markSyncRange(). Now we maintain an unread count // too. And it's likely we'll be adding more values we care about in the // future.) this._account.universe.__notifyModifiedFolder(this._account, this.folderMeta); return pinfo; }, _invokeNextMutexedCall: function() { var callInfo = this._mutexQueue[0], self = this, done = false; this._mutexedCallInProgress = true; logic(this, 'mutexedCall_begin', { name: callInfo.name }); try { var mutexedOpDone = function(err) { if (done) { logic(self, 'tooManyCallbacks', { name: callInfo.name }); return; } logic(self, 'mutexedCall_end', { name: callInfo.name }); logic(self, 'mailslice:mutex-released', { folderId: self.folderId, err: err }); done = true; if (self._mutexQueue[0] !== callInfo) { logic(self, 'mutexInvariantFail', { callName: callInfo.name, mutexName: self._mutexQueue[0].name }); return; } self._mutexQueue.shift(); self.flushExcessCachedBlocks('mutex'); // Although everything should be async, avoid stack explosions by // deferring the execution to a future turn of the event loop. if (self._mutexQueue.length) window.setZeroTimeout(self._invokeNextMutexedCall.bind(self)); else if (self._slices.length === 0) self.folderSyncer.allConsumersDead(); } callInfo.func(mutexedOpDone); } catch (ex) { logic(this, 'mutexedOpErr', { ex: ex }); } }, /** * If you want to modify the state of things in the FolderStorage, or be able * to view the state of the FolderStorage without worrying about some other * logic mutating its state, then use this to schedule your function to run * with (notional) exclusive write access. Because everything is generally * asynchronous, it's assumed your function is still doing work until it calls * the passed-in function to indicate it is done. * * This mutex should not be held longer than required. Specifically, if error * handling determines that we should wait a few seconds to retry a network * operation, then the function should mark itself completed and issue a call * to runMutexed again in the future once the timeout has elapsed. * * Keep in mind that there is nothing actually stopping other code from trying * to manipulate the database. * * It's okay to issue reads against the FolderStorage if the value is * immutable or there are other protective mechanisms in place. For example, * fetching a message body should always be safe even if a block load needs * to occur. But if you wanted to fetch a header, mutate it, and write it * back, then you would want to do all of that with the mutex held; reading * the header before holding the mutex could result in a race. * * @args[ * @param[name String]{ * A short name to identify what operation this is for debugging purposes. * No private user data or sensitive data should be included in the name. * } * @param[func @func[@args[@param[callWhenDone Function]]]]{ * The function to run with (notional) exclusive access to the * FolderStorage. * } * ] */ runMutexed: function(name, func) { var doRun = this._mutexQueue.length === 0; this._mutexQueue.push({ name: name, func: func }); if (doRun) this._invokeNextMutexedCall(); }, /** * This queues the proper upgrade jobs and updates the version, if necessary */ upgradeIfNeeded: function() { if (!this.folderMeta.version || FOLDER_DB_VERSION > this.folderMeta.version) { this._account.universe.performFolderUpgrade(this.folderMeta.id); } }, _issueNewHeaderId: function() { return this._folderImpl.nextId++; }, /** * Create an empty header `FolderBlockInfo` and matching `HeaderBlock`. The * `HeaderBlock` will be inserted into the block map, but it's up to the * caller to insert the returned `FolderBlockInfo` in the right place. */ _makeHeaderBlock: function ifs__makeHeaderBlock( startTS, startUID, endTS, endUID, estSize, ids, headers) { var blockId = $a64.encodeInt(this._folderImpl.nextHeaderBlock++), blockInfo = { blockId: blockId, startTS: startTS, startUID: startUID, endTS: endTS, endUID: endUID, count: ids ? ids.length : 0, estSize: estSize || 0, }, block = { ids: ids || [], headers: headers || [], }; this._dirty = true; this._headerBlocks[blockId] = block; this._dirtyHeaderBlocks[blockId] = block; // Update the server id mapping if we are maintaining one. if (this._serverIdHeaderBlockMapping && headers) { var srvMapping = this._serverIdHeaderBlockMapping; for (var i = 0; i < headers.length; i++) { var header = headers[i]; if (header.srvid) srvMapping[header.srvid] = blockId; } } return blockInfo; }, _insertHeaderInBlock: function ifs__insertHeaderInBlock(header, uid, info, block) { var idx = bsearchForInsert(block.headers, header, cmpHeaderYoungToOld); block.ids.splice(idx, 0, header.id); block.headers.splice(idx, 0, header); this._dirty = true; this._dirtyHeaderBlocks[info.blockId] = block; // Insertion does not need to update start/end TS/UID because the calling // logic is able to handle it. }, _deleteHeaderFromBlock: function ifs__deleteHeaderFromBlock(uid, info, block) { var idx = block.ids.indexOf(uid), header; // Whatever we're looking for should absolutely exist; log an error if it // does not. But just silently return since there's little to be gained // from blowing up the world. if (idx === -1) { logic(this, 'badDeletionRequest', { header: header, uid: uid }); return; } header = block.headers[idx]; // - remove, update counts if (header.flags && header.flags.indexOf('\\Seen') === -1) { this.folderMeta.unreadCount--; } block.ids.splice(idx, 1); block.headers.splice(idx, 1); info.estSize -= $sync.HEADER_EST_SIZE_IN_BYTES; info.count--; this._dirty = true; this._dirtyHeaderBlocks[info.blockId] = block; // - update endTS/endUID if necessary if (idx === 0 && info.count) { header = block.headers[0]; info.endTS = header.date; info.endUID = header.id; } // - update startTS/startUID if necessary if (idx === info.count && idx > 0) { header = block.headers[idx - 1]; info.startTS = header.date; info.startUID = header.id; } }, /** * Split the contents of the given header block into a newer and older block. * The newer info block will be mutated in place; the older block info will * be created and returned. The newer block is filled with data until it * first overflows newerTargetBytes. This method is responsible for updating * the actual containing blocks as well. */ _splitHeaderBlock: function ifs__splitHeaderBlock(splinfo, splock, newerTargetBytes) { // We currently assume a fixed size, so this is easy. var numHeaders = Math.ceil(newerTargetBytes / $sync.HEADER_EST_SIZE_IN_BYTES); if (numHeaders > splock.headers.length) throw new Error("No need to split!"); var olderNumHeaders = splock.headers.length - numHeaders, olderEndHeader = splock.headers[numHeaders], // (This will update the server id mappings for the headers too) olderInfo = this._makeHeaderBlock( // Take the start info from the block, because it may have // been extended beyond the header (for an insertion if // we change back to inserting after splitting.) splinfo.startTS, splinfo.startUID, olderEndHeader.date, olderEndHeader.id, olderNumHeaders * $sync.HEADER_EST_SIZE_IN_BYTES, splock.ids.splice(numHeaders, olderNumHeaders), splock.headers.splice(numHeaders, olderNumHeaders)); var newerStartHeader = splock.headers[numHeaders - 1]; splinfo.count = numHeaders; splinfo.estSize = numHeaders * $sync.HEADER_EST_SIZE_IN_BYTES; splinfo.startTS = newerStartHeader.date; splinfo.startUID = newerStartHeader.id; // this._dirty is already touched by makeHeaderBlock when it dirties the // block it creates. this._dirtyHeaderBlocks[splinfo.blockId] = splock; return olderInfo; }, /** * Create an empty header `FolderBlockInfo` and matching `BodyBlock`. The * `BodyBlock` will be inserted into the block map, but it's up to the * caller to insert the returned `FolderBlockInfo` in the right place. */ _makeBodyBlock: function ifs__makeBodyBlock( startTS, startUID, endTS, endUID, size, ids, bodies) { var blockId = $a64.encodeInt(this._folderImpl.nextBodyBlock++), blockInfo = { blockId: blockId, startTS: startTS, startUID: startUID, endTS: endTS, endUID: endUID, count: ids ? ids.length : 0, estSize: size || 0, }, block = { ids: ids || [], bodies: bodies || {}, }; this._dirty = true; this._bodyBlocks[blockId] = block; this._dirtyBodyBlocks[blockId] = block; if (this._folderImpl.nextBodyBlock % $sync.BLOCK_PURGE_EVERY_N_NEW_BODY_BLOCKS === 0 && !this._messagePurgeScheduled) { this._messagePurgeScheduled = true; this._account.scheduleMessagePurge(this.folderId); } return blockInfo; }, _insertBodyInBlock: function ifs__insertBodyInBlock(body, id, info, block) { function cmpBodyByID(aID, bID) { var aDate = (aID === id) ? body.date : block.bodies[aID].date, bDate = (bID === id) ? body.date : block.bodies[bID].date, d = bDate - aDate; if (d) return d; d = bID - aID; return d; } var idx = bsearchForInsert(block.ids, id, cmpBodyByID); block.ids.splice(idx, 0, id); block.bodies[id] = body; this._dirty = true; this._dirtyBodyBlocks[info.blockId] = block; // Insertion does not need to update start/end TS/UID because the calling // logic is able to handle it. }, _deleteBodyFromBlock: function ifs__deleteBodyFromBlock(id, info, block) { // - delete var idx = block.ids.indexOf(id); var body = block.bodies[id]; if (idx === -1 || !body) { logic(this, 'bodyBlockMissing', { id: id, index: idx, hasBody: !!body }); return; } block.ids.splice(idx, 1); delete block.bodies[id]; info.estSize -= body.size; info.count--; this._dirty = true; this._dirtyBodyBlocks[info.blockId] = block; // - update endTS/endUID if necessary if (idx === 0 && info.count) { info.endUID = id = block.ids[0]; info.endTS = block.bodies[id].date; } // - update startTS/startUID if necessary if (idx === info.count && idx > 0) { info.startUID = id = block.ids[idx - 1]; info.startTS = block.bodies[id].date; } }, /** * Split the contents of the given body block into a newer and older block. * The newer info block will be mutated in place; the older block info will * be created and returned. The newer block is filled with data until it * first overflows newerTargetBytes. This method is responsible for updating * the actual containing blocks as well. */ _splitBodyBlock: function ifs__splitBodyBlock(splinfo, splock, newerTargetBytes) { // Save off the start timestamp/uid; these may have been extended beyond the // delimiting bodies because of the insertion triggering the split. (At // least if we start inserting after splitting again in the future.) var savedStartTS = splinfo.startTS, savedStartUID = splinfo.startUID; var newerBytes = 0, ids = splock.ids, newDict = {}, oldDict = {}, inNew = true, numHeaders = null, i, id, body, idxLast = ids.length - 1; // loop for new traversal; picking a split-point so that there is at least // one item in each block. for (i = 0; i < idxLast; i++) { id = ids[i], body = splock.bodies[id]; newerBytes += body.size; newDict[id] = body; if (newerBytes >= newerTargetBytes) { i++; break; } } // mark the breakpoint; i is pointing at the first old-block message splinfo.count = numHeaders = i; // and these values are from the last processed new-block message splinfo.startTS = body.date; splinfo.startUID = id; // loop for old traversal for (; i < ids.length; i++) { id = ids[i]; oldDict[id] = splock.bodies[id]; } var oldEndUID = ids[numHeaders]; var olderInfo = this._makeBodyBlock( savedStartTS, savedStartUID, oldDict[oldEndUID].date, oldEndUID, splinfo.estSize - newerBytes, // (the older block gets the uids the new/existing block does not want, // leaving `uids` containing only the d ids.splice(numHeaders, ids.length - numHeaders), oldDict); splinfo.estSize = newerBytes; splock.bodies = newDict; // _makeBodyBlock dirties the block it creates and touches _dirty this._dirtyBodyBlocks[splinfo.blockId] = splock; return olderInfo; }, /** * Flush cached blocks that are unlikely to be used again soon. Our * heuristics for deciding what to keep is simple: * - Dirty blocks are always kept; this is required for correctness. * - Header blocks that overlap with live `MailSlice` instances are kept. * * It could also make sense to support some type of MRU tracking, but the * complexity is not currently justified since the live `MailSlice` should * lead to a near-perfect hit rate on immediate actions and the UI's * pre-emptive slice growing should insulate it from any foolish discards * we might make. * * For bodies, since they are larger, and the UI may not always shrink a * slice, only keep around one blockInfo of them, which contain the most * likely immediately needed blockInfos, for instance a direction reversal * in a next/previous navigation. */ flushExcessCachedBlocks: function(debugLabel) { // We only care about explicitly folder-backed slices for cache eviction // purposes. Search filters are sparse and would keep way too much in // memory. var slices = this._slices.filter(function (slice) { return slice.type === 'folder'; }); function blockIntersectsAnySlice(blockInfo) { for (var i = 0; i < slices.length; i++) { var slice = slices[i]; if (tupleRangeIntersectsTupleRange(slice, blockInfo)) { // Here is some useful debug you can uncomment! /* console.log(' slice intersect. slice:', slice.startTS, slice.startUID, slice.endTS, slice.endUID, ' block:', blockInfo.startTS, blockInfo.startUID, blockInfo.endTS, blockInfo.endUID); */ return true; } } return false; } function maybeDiscard(blockType, blockInfoList, loadedBlockInfos, blockMap, dirtyMap, shouldDiscardFunc) { // console.warn('!! flushing', blockType, 'blocks because:', debugLabel); // Go backwards in array, to allow code to keep a count of // blockInfos to keep that favor the most current ones. for (var i = loadedBlockInfos.length - 1; i > -1; i--) { var blockInfo = loadedBlockInfos[i]; // do not discard dirty blocks if (dirtyMap.hasOwnProperty(blockInfo.blockId)) { // console.log(' dirty block:', blockInfo.blockId); continue; } if (shouldDiscardFunc(blockInfo)) { // console.log('discarding', blockType, 'block', blockInfo.blockId); delete blockMap[blockInfo.blockId]; loadedBlockInfos.splice(i, 1); } } } maybeDiscard( 'header', this._headerBlockInfos, this._loadedHeaderBlockInfos, this._headerBlocks, this._dirtyHeaderBlocks, function (blockInfo) { // Do not discard blocks that overlap mail slices. return !blockIntersectsAnySlice(blockInfo); } ); // Keep one body block around if there are open folder slices. If there are // no open slices, discard everything. (If there are no headers then there // isn't really a way to access the bodies.) var keepCount = slices.length ? 1 : 0, foundCount = 0; maybeDiscard( 'body', this._bodyBlockInfos, this._loadedBodyBlockInfos, this._bodyBlocks, this._dirtyBodyBlocks, function(blockInfo) { // For bodies, want to always purge as front end may decide to // never shrink a messages slice, but keep one block around to // avoid wasteful DB IO for commonly grouped operations, for // example, a next/previous message navigation direction change. foundCount += 1; return foundCount > keepCount; } ); }, /** * Called after a timeout to do cleanup of cached blocks to keep memory * low. However, only do the cleanup if there is no more mutex-controlled * work so as to keep likely useful cache items still in memory. */ _flushExcessOnTimeout: function() { this._flushExcessTimeoutId = 0; if (!this.isDead && this._mutexQueue.length === 0) { this.flushExcessCachedBlocks('flushExcessOnTimeout'); } }, /** * Discard the cached block that contains the message header or body in * question. This is intended to be used in cases where we want to re-read * a header or body from disk to get IndexedDB file-backed Blobs to replace * our (likely) memory-backed Blobs. * * This will log, but not throw, an error in the event the block in question * is currently tracked as a dirty block or there is no block that contains * the named message. Both cases indicate an assumption that is being * violated. This should cause unit tests to fail but not break us if this * happens out in the real-world. * * If the block is not currently loaded, no error is triggered. * * This method executes synchronously. * * @method _discardCachedBlockUsingDateAndID * @param type {'header'|'body'} * @param date {Number} * The timestamp of the message in question. * @param id {Number} * The folder-local id we allocated for the message. Not the SUID, not the * server-id. */ _discardCachedBlockUsingDateAndID: function(type, date, id) { var scope = logic.subscope(this, { type: type, date: date, id: id }); var blockInfoList, loadedBlockInfoList, blockMap, dirtyMap; logic(scope, 'discardFromBlock'); if (type === 'header') { blockInfoList = this._headerBlockInfos; loadedBlockInfoList = this._loadedHeaderBlockInfos; blockMap = this._headerBlocks; dirtyMap = this._dirtyHeaderBlocks; } else { blockInfoList = this._bodyBlockInfos; loadedBlockInfoList = this._loadedBodyBlockInfos; blockMap = this._bodyBlocks; dirtyMap = this._dirtyBodyBlocks; } var infoTuple = this._findRangeObjIndexForDateAndID(blockInfoList, date, id), iInfo = infoTuple[0], info = infoTuple[1]; // Asking to discard something that does not exist in a block is a // violated assumption. Log an error. if (!info) { logic(scope, 'badDiscardRequest'); return; } var blockId = info.blockId; // Nothing to do if the block isn't present if (!blockMap.hasOwnProperty(blockId)) return; // Violated assumption if the block is dirty if (dirtyMap.hasOwnProperty(blockId)) { logic(scope, 'badDiscardRequest'); return; } // Discard the block delete blockMap[blockId]; var idxLoaded = loadedBlockInfoList.indexOf(info); // Something is horribly wrong if this is -1. if (idxLoaded !== -1) loadedBlockInfoList.splice(idxLoaded, 1); }, /** * Purge messages from disk storage for size and/or time reasons. This is * only used for IMAP folders and we fast-path out if invoked on ActiveSync. * * This method is invoked as a result of new block allocation as a job / * operation run inside a mutex. This means that we won't be run unless a * synchronization job triggers us and that we won't run until that * synchronization job completes. This is important because it means that * if a user doesn't use the mail app for a long time it's not like a cron * process will purge our synchronized state for everything so that when they * next use the mail app all the information will be gone. Likewise, if the * user is disconnected from the net, we won't purge their cached stuff that * they are still looking at. The non-obvious impact on 'archive' folders * whose first messages are quite some ways in the past is that the accuracy * range for archive folders will have been updated with the current date for * at least whatever the UI needed, so we won't go completely purging archive * folders. * * Our strategy is to pick cut points based on a few heuristics and then go * with the deepest cut. Cuts are time-based and always quantized to the * subsequent local (timezone compensated) midnight for the server in order to * line up with our sync boundaries. The cut point defines an exclusive range * of [0, cutTS). * * The heuristics are: * * - Last (online) access: scan accuracy ranges from the oldest until we run * into one that is less than `$sync.BLOCK_PURGE_ONLY_AFTER_UNSYNCED_MS` * milliseconds old. We clip this against the 'syncRange' interval for the * account. * * - Hard block limits: If there are more than * `$sync.BLOCK_PURGE_HARD_MAX_BLOCK_LIMIT` header or body blocks, then we * issue a cut-point of the start date of the block at that index. The date * will then be quantized, which may effectively result in more blocks being * discarded. * * Deletion is performed by asynchronously, iteratively: * - Making sure the oldest header block is loaded. * - Checking the oldest header in the block. If it is more recent than our * cut point, then we are done. * * What we *do not* do: * - We do not do anything about attachments saved to DeviceStorage. We leave * those around and it's on the user to clean those up from the gallery. * - We do not currently take the size of downloaded embedded images into * account. * * @args[ * @param[callback @func[ * @args[ * @param[numDeleted Number]{ * The number of messages deleted. * } * @param[cutTS DateMS] * ] * ]] * ] */ purgeExcessMessages: function(callback) { this._messagePurgeScheduled = false; var cutTS = Math.max( this._purge_findLastAccessCutPoint(), this._purge_findHardBlockCutPoint(this._headerBlockInfos), this._purge_findHardBlockCutPoint(this._bodyBlockInfos)); if (cutTS === 0) { callback(0, cutTS); return; } // Quantize to the subsequent UTC midnight, then apply the timezone // adjustment that is what our IMAP database lookup does to account for // skew. (See `ImapFolderConn.syncDateRange`) cutTS = quantizeDate(cutTS + DAY_MILLIS); // Update the accuracy ranges by nuking accuracy ranges that are no longer // relevant and updating any overlapped range. var aranges = this._accuracyRanges; var splitInfo = this._findFirstObjIndexForDateRange(aranges, cutTS, cutTS); // we only need to update a range if there was in fact some overlap. if (splitInfo[1]) { splitInfo[1].startTS = cutTS; // then be sure not to splice ourselves... aranges.splice(splitInfo[0] + 1, aranges.length - splitInfo[0]); } else { // do splice things at/after aranges.splice(splitInfo[0], aranges.length - splitInfo[0]); } var headerBlockInfos = this._headerBlockInfos, headerBlocks = this._headerBlocks, deletionCount = 0, // These variables let us detect if the deletion happened fully // synchronously and thereby avoid blowing up the stack. callActive = false, deleteTriggered = false; var deleteNextHeader = function() { // if things are happening synchronously, bail out if (callActive) { deleteTriggered = true; return; } while (true) { // - bail if we ran out of blocks somehow if (!headerBlockInfos.length) { callback(deletionCount, cutTS); return; } // - load the last header block if not currently loaded var blockInfo = headerBlockInfos[headerBlockInfos.length - 1]; if (!this._headerBlocks.hasOwnProperty(blockInfo.blockId)) { this._loadBlock('header', blockInfo, deleteNextHeader); return; } // - get the last header, check it var headerBlock = this._headerBlocks[blockInfo.blockId], lastHeader = headerBlock.headers[headerBlock.headers.length - 1]; if (SINCE(lastHeader.date, cutTS)) { // all done! header is more recent than the cut date callback(deletionCount, cutTS); return; } deleteTriggered = false; callActive = true; deletionCount++; this.deleteMessageHeaderAndBodyUsingHeader(lastHeader, deleteNextHeader); callActive = false; if (!deleteTriggered) return; } }.bind(this); deleteNextHeader(); }, _purge_findLastAccessCutPoint: function() { var aranges = this._accuracyRanges, cutoffDate = $date.NOW() - $sync.BLOCK_PURGE_ONLY_AFTER_UNSYNCED_MS; // When the loop terminates, this is the block we should use to cut, so // start with an invalid value. var iCutRange; for (iCutRange = aranges.length; iCutRange >= 1; iCutRange--) { var arange = aranges[iCutRange - 1]; // We can destroy things that aren't fully synchronized. // NB: this case was intended for search-on-server which is not yet // implemented. if (!arange.fullSync) continue; if (arange.fullSync.updated > cutoffDate) break; } if (iCutRange === aranges.length) return 0; var cutTS = aranges[iCutRange].endTS, syncRangeMS = $sync.SYNC_RANGE_ENUMS_TO_MS[ this._account.accountDef.syncRange] || $sync.SYNC_RANGE_ENUMS_TO_MS['auto'], // Determine the sync horizon, but then subtract an extra day off so // that the quantization does not take a bite out of the sync range syncHorizonTS = $date.NOW() - syncRangeMS - DAY_MILLIS; // If the proposed cut is more recent than our sync horizon, use the sync // horizon. if (STRICTLY_AFTER(cutTS, syncHorizonTS)) return syncHorizonTS; return cutTS; }, _purge_findHardBlockCutPoint: function(blockInfoList) { if (blockInfoList.length <= $sync.BLOCK_PURGE_HARD_MAX_BLOCK_LIMIT) return 0; return blockInfoList[$sync.BLOCK_PURGE_HARD_MAX_BLOCK_LIMIT].startTS; }, /** * Find the first object that contains date ranges whose date ranges contains * the provided date. For use to find the right index in `_accuracyRanges`, * `_headerBlockInfos`, and `_bodyBlockInfos`, all of which are pre-sorted. * * @return[@list[ * @param[index Number]{ * The index of the Object that contains the date, or if there is no such * structure, the index that it should be inserted at. * } * @param[inside Object] * ]] */ _findRangeObjIndexForDate: function ifs__findRangeObjIndexForDate( list, date) { var i; // linear scan for now; binary search later for (i = 0; i < list.length; i++) { var info = list[i]; // - Stop if we will never find a match if we keep going. // If our date is after the end of this range, then it will never fall // inside any subsequent ranges, because they are all chronologically // earlier than this range. if (SINCE(date, info.endTS)) return [i, null]; // therefore BEFORE(date, info.endTS) if (SINCE(date, info.startTS)) return [i, info]; // (Older than the startTS, keep going.) } return [i, null]; }, /** * Find the first object that contains date ranges whose date ranges contains * the provided composite date/UID. For use to find the right index in * `_headerBlockInfos`, and `_bodyBlockInfos`, all of which are pre-sorted. * * @return[@list[ * @param[index Number]{ * The index of the Object that contains the date, or if there is no such * structure, the index that it should be inserted at. * } * @param[inside Object] * ]] */ _findRangeObjIndexForDateAndID: function ifs__findRangeObjIndexForDateAndID( list, date, uid) { var i; // linear scan for now; binary search later for (i = 0; i < list.length; i++) { var info = list[i]; // - Stop if we will never find a match if we keep going. // If our date is after the end of this range, then it will never fall // inside any subsequent ranges, because they are all chronologically // earlier than this range. // If our date is the same and our UID is higher, then likewise we // shouldn't go further because IDs decrease too. if (STRICTLY_AFTER(date, info.endTS) || (date === info.endTS && uid > info.endUID)) return [i, null]; // therefore BEFORE(date, info.endTS) || // (date === info.endTS && uid <= info.endUID) if (STRICTLY_AFTER(date, info.startTS) || (date === info.startTS && uid >= info.startUID)) return [i, info]; // (Older than the startTS, keep going.) } return [i, null]; }, /** * Find the first object that contains date ranges that overlaps the provided * date range. Scans from the present into the past. If endTS is null, get * treat it as being a date infinitely far in the future. */ _findFirstObjIndexForDateRange: function ifs__findFirstObjIndexForDateRange( list, startTS, endTS) { var i; // linear scan for now; binary search later for (i = 0; i < list.length; i++) { var info = list[i]; // - Stop if we will never find a match if we keep going. // If our comparison range starts AFTER the end of this range, then it // does not overlap this range and will never overlap any subsequent // ranges because they are all chronologically earlier than this range. // // nb: We are saying that there is no overlap if one range starts where // the other one ends. This is consistent with the inclusive/exclusive // definition of since/before and our ranges. if (STRICTLY_AFTER(startTS, info.endTS)) return [i, null]; // therefore ON_OR_BEFORE(startTS, info.endTS) // nb: SINCE(endTS, info.startTS) is not right here because the equals // case does not result in overlap because endTS is exclusive. if (endTS === null || STRICTLY_AFTER(endTS, info.startTS)) return [i, info]; // (no overlap yet) } return [i, null]; }, /** * Find the last object that contains date ranges that overlaps the provided * date range. Scans from the past into the present. */ _findLastObjIndexForDateRange: function ifs__findLastObjIndexForDateRange( list, startTS, endTS) { var i; // linear scan for now; binary search later for (i = list.length - 1; i >= 0; i--) { var info = list[i]; // - Stop if we will never find a match if we keep going. // If our comparison range ends ON OR BEFORE the end of this range, then // it does not overlap this range and will never overlap any subsequent // ranges because they are all chronologically later than this range. // // nb: We are saying that there is no overlap if one range starts where // the other one ends. This is consistent with the inclusive/exclusive // definition of since/before and our ranges. if (ON_OR_BEFORE(endTS, info.startTS)) return [i + 1, null]; // therefore STRICTLY_AFTER(endTS, info.startTS) // we match in this entry if the start stamp is before the range's end if (BEFORE(startTS, info.endTS)) return [i, info]; // (no overlap yet) } return [0, null]; }, /** * Find the first object in the list whose `date` falls inside the given * IMAP style date range. If `endTS` is null, find the first object whose * `date` is at least `startTS`. */ _findFirstObjForDateRange: function ifs__findFirstObjForDateRange( list, startTS, endTS) { var i; var dateComparator = endTS === null ? SINCE : IN_BS_DATE_RANGE; for (i = 0; i < list.length; i++) { var date = list[i].date; if (dateComparator(date, startTS, endTS)) return [i, list[i]]; } return [i, null]; }, /** * Find the right block to insert a header/body into using its date and UID. * This is an asynchronous operation because we potentially need to load * blocks from disk. * * == Usage patterns * * - In initial-sync cases and scrolling down through the list, we will * generate messages from a younger-to-older direction. The insertion point * will then likely occur after the last block. * - In update-sync cases, we should be primarily dealing with new mail which * is still retrieved endTS to startTS. The insertion point will start * before the first block and then move backwards within that block. * - Update-sync cases may also encounter messages moved into the folder * from other folders since the last sync. An archive folder is the * most likely case for this, and we would expect random additions with a * high degree of clustering on message date. * - Update-sync cases may experience a lot of apparent message deletion due * to actual deletion or moves to other folders. These can shrink blocks * and we need to consider block merges to avoid pathological behavior. * - Forgetting messages that are no longer being kept alive by sync settings * or apparent user interest. There's no benefit to churn for the sake of * churn, so we can just forget messages in blocks wholesale when we * experience disk space pressure (from ourselves or elsewhere). In that * case we will want to traverse from the startTS messages, dropping them and * consolidating blocks as we go until we have freed up enough space. * * == General strategy * * - If we fall in an existing block and it won't overflow, use it. * - If we fall in an existing block and it would overflow, split it. * - If we fall outside existing blocks, check older and newer blocks in that * order for a non-overflow fit. If we would overflow, pick the existing * block further from the center to perform a split. * - If there are no existing blocks at all, create a new one. * - When splitting, if we are the first or last block, split 2/3 towards the * center and 1/3 towards the edge. The idea is that growth is most likely * to occur near the edges, so concentrate the empty space there without * leaving the center blocks so overloaded they can't accept random * additions without further splits. * - When splitting, otherwise, split equally-ish. * * == Block I/O * * While we can make decisions about where to insert things, we need to have * blocks in memory in order to perform the actual splits. The outcome * of splits can't be predicted because the size of things in blocks is * only known when the block is loaded. * * @args[ * @param[type @oneof['header' 'body']] * @param[date DateMS] * @param[estSizeCost Number]{ * The rough byte cost of whatever we want to stick in a block. * } * @param[thing Object] * @param[blockPickedCallback @func[ * @args[ * @param[blockInfo FolderBlockInfo] * @param[block @oneof[HeaderBlock BodyBlock]] * ] * ]]{ * Callback function to invoke once we have found/created/made-room-for * the thing in the block. This needs to be a callback because if we need * to perform any splits, we require that the block be loaded into memory * first. (For consistency and simplicity, we then made us always return * the block.) * } * ] */ _insertIntoBlockUsingDateAndUID: function ifs__pickInsertionBlocks( type, date, uid, srvid, estSizeCost, thing, blockPickedCallback) { var blockInfoList, loadedBlockInfoList, blockMap, makeBlock, insertInBlock, splitBlock, serverIdBlockMapping; if (type === 'header') { blockInfoList = this._headerBlockInfos; loadedBlockInfoList = this._loadedHeaderBlockInfos; blockMap = this._headerBlocks; serverIdBlockMapping = this._serverIdHeaderBlockMapping; makeBlock = this._bound_makeHeaderBlock; insertInBlock = this._bound_insertHeaderInBlock; splitBlock = this._bound_splitHeaderBlock; } else { blockInfoList = this._bodyBlockInfos; loadedBlockInfoList = this._loadedBodyBlockInfos; blockMap = this._bodyBlocks; serverIdBlockMapping = null; // only headers have the mapping makeBlock = this._bound_makeBodyBlock; insertInBlock = this._bound_insertBodyInBlock; splitBlock = this._bound_splitBodyBlock; } // -- find the current containing block / insertion point var infoTuple = this._findRangeObjIndexForDateAndID(blockInfoList, date, uid), iInfo = infoTuple[0], info = infoTuple[1]; // For database consistency reasons in the worst-case we want to make sure // that we don't update the block info structure until we are also // simultaneously updating the block itself. We use null for sentinel // values. We only update when non-null. var updateInfo = { startTS: null, startUID: null, endTS: null, endUID: null }; // -- not in a block, find or create one if (!info) { // - Create a block if no blocks exist at all. if (blockInfoList.length === 0) { info = makeBlock(date, uid, date, uid); blockInfoList.splice(iInfo, 0, info); loadedBlockInfoList.push(info); } // - Is there a trailing/older dude and we fit? else if (iInfo < blockInfoList.length && (blockInfoList[iInfo].estSize + estSizeCost < $sync.MAX_BLOCK_SIZE)) { info = blockInfoList[iInfo]; // We are chronologically/UID-ically more recent, so check the end range // for expansion needs. if (STRICTLY_AFTER(date, info.endTS)) { updateInfo.endTS = date; updateInfo.endUID = uid; } else if (date === info.endTS && uid > info.endUID) { updateInfo.endUID = uid; } } // - Is there a preceding/younger dude and we fit? else if (iInfo > 0 && (blockInfoList[iInfo - 1].estSize + estSizeCost < $sync.MAX_BLOCK_SIZE)) { info = blockInfoList[--iInfo]; // We are chronologically less recent, so check the start range for // expansion needs. if (BEFORE(date, info.startTS)) { updateInfo.startTS = date; updateInfo.startUID = uid; } else if (date === info.startTS && uid < info.startUID) { updateInfo.startUID = uid; } } // Any adjacent blocks at this point are overflowing, so it's now a // question of who to split. We pick the one further from the center that // exists. // - Preceding (if possible and) suitable OR the only choice else if ((iInfo > 0 && iInfo < blockInfoList.length / 2) || (iInfo === blockInfoList.length)) { info = blockInfoList[--iInfo]; // We are chronologically less recent, so check the start range for // expansion needs. if (BEFORE(date, info.startTS)) { updateInfo.startTS = date; updateInfo.startUID = uid; } else if (date === info.startTS && uid < info.startUID) { updateInfo.startUID = uid; } } // - It must be the trailing dude else { info = blockInfoList[iInfo]; // We are chronologically/UID-ically more recent, so check the end range // for expansion needs. if (STRICTLY_AFTER(date, info.endTS)) { updateInfo.endTS = date; updateInfo.endUID = uid; } else if (date === info.endTS && uid > info.endUID) { updateInfo.endUID = uid; } } } // (info now definitely exists and is definitely in blockInfoList) function processBlock(block) { // 'this' gets explicitly bound // -- perform the insertion // - update block info if (updateInfo.startTS !== null) { info.startTS = updateInfo.startTS; } if (updateInfo.startUID !== null) { info.startUID = updateInfo.startUID; } if (updateInfo.endTS !== null) { info.endTS = updateInfo.endTS; } if (updateInfo.endUID !== null) { info.endUID = updateInfo.endUID; } // We could do this after the split, but this makes things simpler if // we want to factor in the newly inserted thing's size in the // distribution of bytes. info.estSize += estSizeCost; info.count++; // - actual insertion insertInBlock(thing, uid, info, block); // -- split if necessary if (info.count > 1 && info.estSize >= $sync.MAX_BLOCK_SIZE) { // - figure the desired resulting sizes var firstBlockTarget; // big part to the center at the edges (favoring front edge) if (iInfo === 0) firstBlockTarget = $sync.BLOCK_SPLIT_SMALL_PART; else if (iInfo === blockInfoList.length - 1) firstBlockTarget = $sync.BLOCK_SPLIT_LARGE_PART; // otherwise equal split else firstBlockTarget = $sync.BLOCK_SPLIT_EQUAL_PART; // - split var olderInfo; olderInfo = splitBlock(info, block, firstBlockTarget); blockInfoList.splice(iInfo + 1, 0, olderInfo); loadedBlockInfoList.push(olderInfo); // - figure which of the blocks our insertion went in if (BEFORE(date, olderInfo.endTS) || ((date === olderInfo.endTS) && (uid <= olderInfo.endUID))) { iInfo++; info = olderInfo; block = blockMap[info.blockId]; } } // otherwise, no split necessary, just use it if (serverIdBlockMapping && srvid) serverIdBlockMapping[srvid] = info.blockId; if (blockPickedCallback) { blockPickedCallback(info, block); } } if (blockMap.hasOwnProperty(info.blockId)) processBlock.call(this, blockMap[info.blockId]); else this._loadBlock(type, info, processBlock.bind(this)); }, /** * Run deferred calls until we run out of deferred calls or _pendingLoads goes * non-zero again. */ _runDeferredCalls: function ifs__runDeferredCalls() { while (this._deferredCalls.length && this._pendingLoads.length === 0) { var toCall = this._deferredCalls.shift(); try { toCall(); } catch (ex) { logic(this, 'callbackErr', { ex: ex }); } } }, _findBlockInfoFromBlockId: function(type, blockId) { var blockInfoList; if (type === 'header') blockInfoList = this._headerBlockInfos; else blockInfoList = this._bodyBlockInfos; for (var i = 0; i < blockInfoList.length; i++) { var blockInfo = blockInfoList[i]; if (blockInfo.blockId === blockId) return blockInfo; } return null; }, /** * Request the load of the given block and the invocation of the callback with * the block when the load completes. */ _loadBlock: function ifs__loadBlock(type, blockInfo, callback) { var blockId = blockInfo.blockId; var aggrId = type + blockId; if (this._pendingLoads.indexOf(aggrId) !== -1) { this._pendingLoadListeners[aggrId].push(callback); return; } var index = this._pendingLoads.length; this._pendingLoads.push(aggrId); this._pendingLoadListeners[aggrId] = [callback]; var self = this; function onLoaded(block) { if (!block) logic(self, 'badBlockLoad', { type: type, blockId: blockId }); logic(self, 'loadBlock_end', { type: type, blockId: blockId, block: block }); if (type === 'header') { self._headerBlocks[blockId] = block; self._loadedHeaderBlockInfos.push(blockInfo); } else { self._bodyBlocks[blockId] = block; self._loadedBodyBlockInfos.push(blockInfo); } self._pendingLoads.splice(self._pendingLoads.indexOf(aggrId), 1); var listeners = self._pendingLoadListeners[aggrId]; delete self._pendingLoadListeners[aggrId]; for (var i = 0; i < listeners.length; i++) { try { listeners[i](block); } catch (ex) { logic(self, 'callbackErr', { ex: ex }); } } if (self._pendingLoads.length === 0) self._runDeferredCalls(); // Ask for cleanup of old blocks in case the UI is not shrinking // any slices. if (self._mutexQueue.length === 0 && !self._flushExcessTimeoutId) { self._flushExcessTimeoutId = setTimeout( self._bound_flushExcessOnTimeout, // Choose 5 seconds, since it is a human-scale value around // the order of how long we expect it would take the user // to realize they hit the opposite arrow navigation button // from what they meant. 5000 ); } } logic(this, 'loadBlock_begin', { type: type, blockId: blockId }); if (type === 'header') this._imapDb.loadHeaderBlock(this.folderId, blockId, onLoaded); else this._imapDb.loadBodyBlock(this.folderId, blockId, onLoaded); }, _deleteFromBlock: function ifs__deleteFromBlock(type, date, id, callback) { var blockInfoList, loadedBlockInfoList, blockMap, deleteFromBlock; var scope = logic.subscope(this, { type: type, date: date, id: id }); logic(scope, 'deleteFromBlock'); if (type === 'header') { blockInfoList = this._headerBlockInfos; loadedBlockInfoList = this._loadedHeaderBlockInfos; blockMap = this._headerBlocks; deleteFromBlock = this._bound_deleteHeaderFromBlock; } else { blockInfoList = this._bodyBlockInfos; loadedBlockInfoList = this._loadedBodyBlockInfos; blockMap = this._bodyBlocks; deleteFromBlock = this._bound_deleteBodyFromBlock; } var infoTuple = this._findRangeObjIndexForDateAndID(blockInfoList, date, id), iInfo = infoTuple[0], info = infoTuple[1]; // If someone is asking for us to delete something, there should definitely // be a block that includes it! if (!info) { log('badDeletionRequest'); return; } function processBlock(block) { // The delete function is in charge of updating the start/end TS/ID info // because it knows about the internal block structure to do so. deleteFromBlock(id, info, block); // - Nuke the block if it's empty if (info.count === 0) { blockInfoList.splice(iInfo, 1); delete blockMap[info.blockId]; loadedBlockInfoList.splice(loadedBlockInfoList.indexOf(info), 1); this._dirty = true; if (type === 'header') this._dirtyHeaderBlocks[info.blockId] = null; else this._dirtyBodyBlocks[info.blockId] = null; } if (callback) callback(); } if (blockMap.hasOwnProperty(info.blockId)) processBlock.call(this, blockMap[info.blockId]); else this._loadBlock(type, info, processBlock.bind(this)); }, sliceOpenSearch: function fs_sliceOpenSearch(slice) { this._slices.push(slice); }, /** * Track a new slice that wants to start from the most recent messages we know * about in the folder. * * If we have previously synchronized the folder, we will return the known * messages from the database. If we are also online, we will trigger a * refresh covering the time range of the messages. * * If we have not previously synchronized the folder, we will initiate * synchronization starting from 'now'. * * For IMAP, an important ramification is that merely opening a slice may not * cause us to synchronize all the way up to 'now'. The slice's consumer will * need to keep checking 'atTop' and 'userCanGrowUpwards' and trigger * synchronizations until they both go false. For consumers that really only * want us to synchronize the most recent messages, they should either * consider purging our storage first or creating a new API that deals with * the change in invariants so that gaps in synchronized intervals can exist. * * Note: previously, we had a function called "sliceOpenFromNow" that would * provide guarantees that the slice was accurate and grown from 'now' * backwards, but at the very high cost of potentially requiring the user to * wait until some amount of synchronization was required. This resulted in * bad UX from a latency perspective and also actually increased * synchronization complexity because we had to implement multiple sync * heuristics. Our new approach is much better from a latency perspective but * may result in UI complications since we can be so far behind 'now'. * * @args[ * @param[forceRefresh #:optional Boolean]{ * Should we ensure that we try and perform a refresh if we are online? * Without this flag, we may decide not to attempt to trigger a refresh * if our data is sufficiently recent. * } * ] */ sliceOpenMostRecent: function fs_sliceOpenMostRecent(slice, forceRefresh) { // Set the status immediately so that the UI will convey that the request is // being processed, even though it might take a little bit to acquire the // mutex. slice.setStatus('synchronizing', false, true, false, SYNC_START_MINIMUM_PROGRESS); var sliceFn = this._sliceOpenMostRecent.bind(this, slice, forceRefresh); // Local-only folders don't have a real sync process, so we don't // need to hold the mutex when syncing; all mutating operations // run in job-ops. if (this.isLocalOnly) { sliceFn(function fakeReleaseMutex() { /* nothing to do */ }); } else { this.runMutexed('sync', sliceFn); } }, _sliceOpenMostRecent: function fs__sliceOpenMostRecent(slice, forceRefresh, releaseMutex) { // We only put the slice in the list of slices now that we have the mutex // in order to avoid having the slice have data fed into it if there were // other synchronizations already in progress. this._slices.push(slice); // refresh the headerCount; it might have gotten stalled out if a // double-open happened. slice.headerCount = this.headerCount; var doneCallback = function doneSyncCallback(err, reportSyncStatusAs, moreExpected) { if (!reportSyncStatusAs) { if (err) reportSyncStatusAs = 'syncfailed'; else reportSyncStatusAs = 'synced'; } if (moreExpected === undefined) moreExpected = false; slice.waitingOnData = false; slice.setStatus(reportSyncStatusAs, true, moreExpected, true); this._curSyncSlice = null; releaseMutex(err); }.bind(this); // -- grab from database if we have ever synchronized this folder // OR if it's synthetic if (this._accuracyRanges.length || this.isLocalOnly) { // We can only trigger a refresh if we are online. Our caller may want to // force the refresh, ignoring recency data. (This logic was too ugly as // a straight-up boolean/ternarny combo.) var triggerRefresh; if (this._account.universe.online && this.folderSyncer.syncable && !this.isLocalOnly) { if (forceRefresh) triggerRefresh = 'force'; else triggerRefresh = true; } else { triggerRefresh = false; } slice.waitingOnData = 'db'; this.getMessagesInImapDateRange( 0, null, $sync.INITIAL_FILL_SIZE, $sync.INITIAL_FILL_SIZE, // trigger a refresh if we are online this.onFetchDBHeaders.bind( this, slice, triggerRefresh, doneCallback, releaseMutex) ); return; } // (we have never synchronized this folder) // -- no work to do if we are offline or synthetic folder if (!this._account.universe.online || this.isLocalOnly) { doneCallback(); return; } // If the folder can't be synchronized right now, just report the sync as // blocked. We'll update it soon enough. if (!this.folderSyncer.syncable) { console.log('Synchronization is currently blocked; waiting...'); doneCallback(null, 'syncblocked', true); return; } // -- Bad existing data, issue a sync var progressCallback = slice.setSyncProgress.bind(slice); var syncCallback = function syncCallback(syncMode, ignoreHeaders) { slice.waitingOnData = syncMode; if (ignoreHeaders) { slice.ignoreHeaders = true; } this._curSyncSlice = slice; // headerCount is updated as changes occur, but as a base case we need to // update it here. (Note: the slice also initialized itself with this // value when created, but since this is by definition an async // callback...) slice.headerCount = this.headerCount; }.bind(this); // The slice flags are not yet valid; we are primarily interested in having // atTop be true when splice notifications for generated as headers are // added. slice._updateSliceFlags(); this.folderSyncer.initialSync( slice, $sync.INITIAL_SYNC_DAYS, syncCallback, doneCallback, progressCallback); }, /** * The slice wants more headers. Grab from the database and/or sync as * appropriate to get more headers. If there is a cost, require an explicit * request to perform the sync. * * We can think of there existing ~2 classes of headers that we might return * and our returned headers will always consist of only 1 of these classes at * a time. * * 1a) Headers from the database that are known to be up-to-date because they * were recently synchronized or refreshed. * * 1b) Headers from the database that need to be refreshed because our * information is old enough that we might be out of sync with the server. * For ActiveSync, no messages will ever be in this state. For IMAP, this * is determined by checking the accuracy ranges and the REFRESH_THRESH_MS * constant. Logic related to this lives in `ImapFolderSyncer`. * * 2) Headers that we need to synchronize with a growSync. This only exists * for IMAP. * * * The steps we actually perform: * * - Try and get messages from the database. If the database knows about * any, we will add them to the slice. * * - If there were any messages and `FolderSyncer.canGrowSync` is true, check * the time-span covered by the messages from the database. If any of the * time-span is not sufficiently recently refreshed, issue a refresh over the * required time interval to bring those messages up-to-date. * * - Return if there were any headers. * * - Issue a grow request. Start with the day adjacent to the furthest known * message in the direction of growth. We could alternately try and use * the accuracy range to start even further away. However, our growth * process likes to keep going until it hits a message, and that's when * it would commit its sync process, so the accuracy range is unlikely * to buy us anything additional at the current time. */ growSlice: function ifs_growSlice(slice, dirMagnitude, userRequestsGrowth) { // If the user requested synchronization, provide UI feedback immediately, // otherwise, let the method set this state if/when we actually decide to // talk to the server. if (userRequestsGrowth) slice.setStatus('synchronizing', false, true, false, SYNC_START_MINIMUM_PROGRESS); this.runMutexed( 'grow', this._growSlice.bind(this, slice, dirMagnitude, userRequestsGrowth)); }, _growSlice: function ifs__growSlice(slice, dirMagnitude, userRequestsGrowth, releaseMutex) { var dir, desiredCount; var batchHeaders = []; // --- process messages var gotMessages = function gotMessages(headers, moreExpected) { if (headers.length === 0) { // no array manipulation required } if (dir === PASTWARDS) { batchHeaders = batchHeaders.concat(headers); } else { // dir === FUTUREWARDS batchHeaders = headers.concat(batchHeaders); } if (moreExpected) return; // -- callbacks which may or may not get used var doneCallback = function doneGrowCallback(err) { // In a refresh, we may have asked for more than we know about in case // of a refresh at the edge where the database didn't have all the // headers we wanted, so latch it now. slice.desiredHeaders = slice.headers.length; slice.waitingOnData = false; slice.setStatus(err ? 'syncfailed' : 'synced', true, false, true); this._curSyncSlice = null; releaseMutex(err); }.bind(this); var progressCallback = slice.setSyncProgress.bind(slice); // -- Handle already-known headers if (batchHeaders.length) { var refreshInterval; // - compute refresh interval, if needed // offline? don't need a refresh! if (!this._account.universe.online || // disabled account? don't need a refresh! !this._account.enabled || // can't incrementally refresh? don't need a refresh! !this.folderSyncer.canGrowSync) { refreshInterval = null; } else { // - Figure out refresh range. // We want to make sure that our refresh covers any gaps between the // last message in our slice and the first message that we retrieved. // NB: endTS is exclusive, so we need to pad it out by a day relative // to a known message if we want to make sure it gets covered by the // sync range. // NB: We quantize to whole dates, but compensate for server timezones // so that our refresh actually lines up with the messages we are // interested in. (We want the date in the server's timezone, so we // add the timezone to be relative to that timezone.) We do adjust // startTS for the timezone offset in here rather than in the // quantization blow below because we do not timezone adjust the oldest // full sync date because it's already in the right 'units'. var highestLegalEndTS; // If we hit the highestLegalEndTS, flag that we should mark endTS as // open-ended if we decide we do need to refresh. var openEndTS = false; var startTS, endTS; if (dir === PASTWARDS) { var oldestHeader = batchHeaders[batchHeaders.length - 1]; // If we were always going to sync the entire day, we could // subtract an entire day off of slice.startTS, but we are planning // to start grabbing less than whole days, so we want to leave it // up to checkAccuracyCoverageNeedingRefresh to get rid of any // redundant coverage of what we are currently looking at. // // We do want to cap the date so that we don't re-refresh today and // any other intervening days spuriously. When we sync we only use // an endTS of tz-adjusted NOW(), so our rounding up can be too // aggressive otherwise and prevent range shrinking. We call // quantizeDateUp afterwards so that if any part of the day is still // covered we will have our refresh cover it. // // NB: We use OPEN_REFRESH_THRESH_MS here because since we are // growing past-wards, we don't really care about refreshing things // in our future. This is not the case for FUTUREWARDS. highestLegalEndTS = NOW() - $sync.OPEN_REFRESH_THRESH_MS; endTS = slice.startTS + $date.DAY_MILLIS; // (Note that unlike the else case, we don't need to worry about // IMAP_SEARCH_AMBIGUITY since by definition the message can't // belong to a time range we haven't searched over. Well, ignoring // daylight-savings-time snafus, but that would be a super edge // case we're willing to glitch on.) if (this.headerIsOldestKnown(oldestHeader.date, oldestHeader.id)) { startTS = this.getOldestFullSyncDate(); // Because of deletion ambiguity and our desire to make sure we are // up-to-date for all of the headers we are telling the user about, // we need to subtract off an extra ambiguity interval. (Otherwise // we might only declare a message deleted after a user had already // scrolled beyond it! Oops!) } else { startTS = oldestHeader.date - $sync.IMAP_SEARCH_AMBIGUITY_MS; } } else { // dir === FUTUREWARDS // Unlike PASTWARDS, we do want to be more aggressively up-to-date // about the future, so only subtract off the grow range coverage. // (If we didn't subtract anything off, quick scrolling back and // forth could cause us to refresh more frequently than // GROW_REFRESH_THRESH_MS, which is not what we want.) highestLegalEndTS = NOW() - $sync.GROW_REFRESH_THRESH_MS; var youngestHeader = batchHeaders[0]; // see the PASTWARDS case for why we don't add a day to this startTS = slice.endTS; endTS = youngestHeader.date + $date.DAY_MILLIS; } // We do not want this clamped/saturated case quantized, but we do // want all the (other) future-dated endTS cases quantized. if (STRICTLY_AFTER(endTS, highestLegalEndTS)) { endTS = highestLegalEndTS; openEndTS = true; } else { endTS = quantizeDate(endTS); } // Now, it's not super-likely, but it is possible that because of // clock skew or what not that our startTS could end up after our // endTS, which we do not want. Now, we could just clamp this, // but since we know the result would be a zero-coverage range, // we can just set the refreshInterval to null and be done. if (SINCE(startTS, endTS)) refreshInterval = null; else refreshInterval = this.checkAccuracyCoverageNeedingRefresh( quantizeDate(startTS), endTS, // quantized above except when it would go into the future. $sync.GROW_REFRESH_THRESH_MS); } // We could also send the headers in as they come across the wire, // but we expect to be dealing in bite-sized requests, so that could // be overkill. slice.batchAppendHeaders( batchHeaders, dir === PASTWARDS ? -1 : 0, // !!refreshInterval is more efficient, but this way we can reuse // doneCallback() below in the else case simply. true); // If the database had fewer headers than are requested, it's possible // the refresh may give us extras, so allow those to be reported. slice.desiredHeaders = Math.max(slice.headers.length, desiredCount); if (refreshInterval && // If the values are the same, by definition we have nothing to do, // but more importantly, the rounding might not improve the // situation, which could result in pathological sync failure on // gmail where it returns all the messages it knows about. refreshInterval.startTS !== refreshInterval.endTS) { // If growth was not requested, make sure we convey server traffic is // happening. if (!userRequestsGrowth) slice.setStatus('synchronizing', false, true, false, SYNC_START_MINIMUM_PROGRESS); this.folderSyncer.refreshSync( slice, dir, quantizeDate(refreshInterval.startTS), // If we didn't shrink the endTS and we flagged to be open-ended, then // use null. But if we did shrink the range, then there's no need to // go open-ended. (openEndTS && refreshInterval.endTS === highestLegalEndTS) ? null : quantizeDateUp(refreshInterval.endTS), /* origStartTS */ null, doneCallback, progressCallback); } else { doneCallback(); } return; } // -- grow! // - do not grow if offline / no support / no user request if (!this._account.universe.online || !this.folderSyncer.canGrowSync || !userRequestsGrowth) { if (this.folderSyncer.syncable) slice.sendEmptyCompletion(); releaseMutex(null); return; } if (!userRequestsGrowth) slice.setStatus('synchronizing', false, true, false, SYNC_START_MINIMUM_PROGRESS); this._curSyncSlice = slice; // headerCount is updated as changes occur, but as a base case we need to // update it here. (Note: the slice also initialized itself with this // value when created, but since this is by definition an async // callback...) slice.headerCount = this.headerCount; slice.waitingOnData = 'grow'; // We only add the desired count now that we are sure we are growing; if // we did it earlier we might boost the desiredHeaders count and then // not sync, resulting in the next time we do grow fetching more than we // want. slice.desiredHeaders += desiredCount; // TODO: when we support partial day sync, these growth steps will need // to be adjusted by 1-day if day covering the edge message has not been // fully synchronized. this.folderSyncer.growSync( slice, dir, dir === PASTWARDS ? quantizeDate(slice.startTS) : quantizeDate(slice.endTS + $date.DAY_MILLIS), $sync.INITIAL_SYNC_GROWTH_DAYS, doneCallback, progressCallback); }.bind(this); // The front end may not be calling shrink any more, to reduce // complexity for virtual scrolling. So be sure to clear caches // that are not needed, to avoid a large memory growth from // keeping the header bodies as the user does next/previous // navigation. if (this._mutexQueue.length === 0) { this.flushExcessCachedBlocks('grow'); } // --- request messages if (dirMagnitude < 0) { dir = FUTUREWARDS; desiredCount = -dirMagnitude; this.getMessagesAfterMessage( slice.endTS, slice.endUID, desiredCount, gotMessages); } else { dir = PASTWARDS; desiredCount = dirMagnitude; this.getMessagesBeforeMessage( slice.startTS, slice.startUID, desiredCount, gotMessages); } }, /** * A notification from a slice that it is has reduced the span of time that it * covers. We use this to run a cache eviction if there is not currently a * mutex held. */ sliceShrunk: function fs_sliceShrunk(slice) { if (this._mutexQueue.length === 0) this.flushExcessCachedBlocks('shrunk'); }, /** * Refresh our understanding of the time range covered by the messages * contained in the slice, plus expansion to the bounds of our known sync * date boundaries if the messages are the first/last known message. * * In other words, if the most recently known message is from a week ago and * that is the most recent message the slice is displaying, then we will * expand our sync range to go all the way through today. Likewise, if the * oldest known message is from two weeks ago and is in the slice, but we * scanned for messages all the way back to 1990 then we will query all the * way back to 1990. And if we have no messages in the slice, then we use the * full date bounds. */ refreshSlice: function fs_refreshSlice(slice) { // Set the status immediately so that the UI will convey that the request is // being processed, even though it might take a little bit to acquire the // mutex. slice.setStatus('synchronizing', false, true, false, 0.0); var refreshFn = this._refreshSlice.bind(this, slice, false); // Local-only folders don't have a real sync process, so we don't // need to hold the mutex when syncing; all mutating operations // run in job-ops. if (this.isLocalOnly) { refreshFn(function fakeReleaseMutex() { /* nothing to do */ }); } else { this.runMutexed('refresh', refreshFn); } }, _refreshSlice: function fs__refreshSlice(slice, checkOpenRecency, releaseMutex) { var doneCallback = function refreshDoneCallback(err, bisectInfo, numMessages) { slice._onAddingHeader = null; var reportSyncStatusAs = 'synced'; switch (err) { case 'aborted': case 'unknown': reportSyncStatusAs = 'syncfailed'; break; } releaseMutex(err); slice.waitingOnData = false; slice.setStatus(reportSyncStatusAs, true, false, false, null, newEmailCount); return undefined; }.bind(this); // If the slice is dead, its startTS and endTS will be set to // null, so there is no range available to refresh. (See Bug 941991.) if (slice.isDead) { console.log('MailSlice: Attempted to refresh a dead slice.'); doneCallback('unknown'); return; } slice.waitingOnData = 'refresh'; var startTS = slice.startTS, endTS = slice.endTS, // In the event we grow the startTS to the dawn of time, then we want // to also provide the original startTS so that the bisection does not // need to scan through years of empty space. origStartTS = null, // If we are refreshing through 'now', we will count the new messages we // hear about and update this.newEmailCount once the sync completes. If // we are performing any othe sync, the value will not be updated. newEmailCount = null; // - Grow endTS // If the endTS lines up with the most recent known message for the folder, // then remove the timestamp constraint so it goes all the way to now. // OR if we just have no known messages if (this.headerIsYoungestKnown(endTS, slice.endUID)) { var prevTS = endTS; newEmailCount = 0; /** * Increment our new email count if the following conditions are met: * 1. This header is younger than the youngest one before sync * 2. and this hasn't already been seen. * @param {HeaderInfo} header The header being added. */ slice._onAddingHeader = function(header, currentSlice) { if (SINCE(header.date, prevTS) && (!header.flags || header.flags.indexOf('\\Seen') === -1)) { newEmailCount += 1; if (slice.onNewHeader) slice.onNewHeader(header); } }.bind(this); endTS = null; } else { // We want the range to include the day; since it's an exclusive range // quantized to midnight, we need to adjust forward a day and then // quantize. We also need to compensate for the timezone; we want this // time in terms of server time, so we add the timezone offset. endTS = quantizeDate(endTS + DAY_MILLIS); } // - Grow startTS // Grow the start-stamp to include the oldest continuous accuracy range // coverage date. Keep original date around for bisect per above. if (this.headerIsOldestKnown(startTS, slice.startUID)) { origStartTS = quantizeDate(startTS); startTS = this.getOldestFullSyncDate(); // Because of deletion ambiguity and our desire to make sure we are // up-to-date for all of the headers we are telling the user about, // we need to subtract off an extra ambiguity interval. (Otherwise // we might only declare a message deleted after a user had already // scrolled beyond it! Oops!) } else { startTS -= $sync.IMAP_SEARCH_AMBIGUITY_MS; } // quantize the start date if (startTS) startTS = quantizeDate(startTS); // In the initial open case, we support a constant that allows us to // fast-path out without bothering the server. if (checkOpenRecency) { // We use now less the refresh threshold as the accuracy range end-post; // since markSyncRange uses NOW() when 'null' is provided (which it will // be for a sync through now), this all works out consistently. if (this.checkAccuracyCoverageNeedingRefresh( startTS, endTS || NOW() - $sync.OPEN_REFRESH_THRESH_MS, $sync.OPEN_REFRESH_THRESH_MS) === null) { doneCallback(); return; } } // The choice of PASTWARDS/FUTUREWARDS impacts the direction our chunks // happen if we have to bisect (if that happens) and (eventually) the // direction new bodies are fetched. // // There are arguments for both choices: // // Initial slice open refresh: // - PASTWARDS: Show the user the newest messages, but at the cost of a // gap between the known messages and these new messages we are // synchronizing in. The gap is potentially confusing and ordering could // also be confusing to the user. // - FUTUREWARDS: Avoid that gap, having the scrolling make sense. // There is a pathological case here where we are ridiculously out-of-date // and it would take the user a loooong time to sync all the way back up // to now and it would be better to just restart with an initial deepening // sync and/or throw things away. Arguably, these are cases that should // be explicitly handled outside of us. // // Manual refresh: // - PASTWARDS: Newest first. // - FUTUREWARDS: Have the messages show up in the order they were received. // // We currently choose FUTUREWARDS to avoid the gap and have messages show // up chronologically. this.folderSyncer.refreshSync( slice, FUTUREWARDS, startTS, endTS, origStartTS, doneCallback, slice.setSyncProgress.bind(slice)); }, _resetAndResyncSlice: function(slice, forceRefresh, releaseMutex) { this._slices.splice(this._slices.indexOf(slice), 1); if (releaseMutex) this._sliceOpenMostRecent(slice, forceRefresh, releaseMutex); else this.sliceOpenMostRecent(slice, forceRefresh); }, dyingSlice: function ifs_dyingSlice(slice) { var idx = this._slices.indexOf(slice); this._slices.splice(idx, 1); // If this was a folder-backed slice, we potentially can now free up a lot // of cached memory, so do that. if (slice.type === 'folder') { this.flushExcessCachedBlocks('deadslice'); } if (this._slices.length === 0 && this._mutexQueue.length === 0) { this.folderSyncer.allConsumersDead(); } }, /** * Receive messages directly from the database (streaming). */ onFetchDBHeaders: function(slice, triggerRefresh, doneCallback, releaseMutex, headers, moreMessagesComing) { var triggerNow = false; if (!moreMessagesComing && triggerRefresh) { moreMessagesComing = true; triggerNow = true; } // (always call this even if there are no headers so metadata like // headerCount can be propagated.) // Claim there are more headers coming since we will trigger setStatus // right below and we want that to be the only edge transition. slice.batchAppendHeaders(headers, -1, true); if (!moreMessagesComing) { slice.desiredHeaders = slice.headers.length; doneCallback(); } else if (triggerNow) { slice.desiredHeaders = slice.headers.length; // refreshSlice expects this to be null for two reasons: // 1) Invariant about only having one sync-like thing happening at a time. // 2) We want to generate header deltas rather than initial filling, // and this is keyed off of whether the slice is the current sync // slice. this._curSyncSlice = null; // We want to have the refresh check its refresh recency range unless we // have been explicitly told to force a refresh. var checkOpenRecency = triggerRefresh !== 'force'; this._refreshSlice(slice, checkOpenRecency, releaseMutex); } }, sliceQuicksearch: function ifs_sliceQuicksearch(slice, searchParams) { }, getYoungestMessageTimestamp: function() { if (!this._headerBlockInfos.length) return 0; return this._headerBlockInfos[0].endTS; }, /** * Return true if the identified header is the most recent known message for * this folder as part of our fully-synchronized time-span. Messages known * because of sparse searches do not count. If null/null is passed and there * are no known headers, we will return true. */ headerIsYoungestKnown: function(date, uid) { // NB: unlike oldest known, this should not actually be impacted by messages // found by search. if (!this._headerBlockInfos.length) return (date === null && uid === null); var blockInfo = this._headerBlockInfos[0]; return (date === blockInfo.endTS && uid === blockInfo.endUID); }, getOldestMessageTimestamp: function() { if (!this._headerBlockInfos.length) return 0; return this._headerBlockInfos[this._headerBlockInfos.length - 1].startTS; }, /** * Return true if the identified header is the oldest known message for this * folder as part of our fully-synchronized time-span. Messages known because * of sparse searches do not count. If null/null is passed and there are no * known headers, we will return true. */ headerIsOldestKnown: function(date, uid) { // TODO: when we implement search, this logic will need to be more clever // to check our full-sync range since we may indeed have cached messages // from way in the past. if (!this._headerBlockInfos.length) return (date === null && uid === null); var blockInfo = this._headerBlockInfos[this._headerBlockInfos.length - 1]; return (date === blockInfo.startTS && uid === blockInfo.startUID); }, /** * What is the most recent date we have fully synchronized through? */ getNewestFullSyncDate: function() { // If we have any accuracy range, it should be what we want. if (this._accuracyRanges.length) return this._accuracyRanges[0].endTS; // If we have no accuracy ranges, then 0 at least safely indicates we are // not up-to-date. return 0; }, /** * What is the oldest date we have fully synchronized through per our * accuracy information? */ getOldestFullSyncDate: function() { // Start at the oldest index and run towards the newest until we find a // fully synced range or run out of ranges. // // We used to start at the newest and move towards the oldest since this // checked our fully-synced-from-now invariant, but that invariant has now // gone by the wayside and is not required for correctness for the purposes // of us/our callers. var idxAR = this._accuracyRanges.length - 1; // Run futurewards in time until we find one without a fullSync or run out while (idxAR >= 0 && !this._accuracyRanges[idxAR].fullSync) { idxAR--; } // Sanity-check, use. var syncTS; if (idxAR >= 0) syncTS = this._accuracyRanges[idxAR].startTS; else syncTS = NOW(); return syncTS; }, /** * Are we synchronized close enough to 'now' so that a refresh of the time * interval will include new message received today? This relies on our * IMAP sync generally operating on day granularities. */ syncedToToday: function() { if (!this.folderSyncer.canGrowSync) return true; var newestSyncTS = this.getNewestFullSyncDate(); return SINCE(newestSyncTS, quantizeDate(NOW())); }, /** * Are we synchronized as far back in time as we are able to synchronize? * * If true, this means that a refresh of the oldest known message should * result in the refresh also covering through `$sync.OLDEST_SYNC_DATE.` * Once this becomes true for a folder, it will remain true unless we * perform a refresh through the dawn of time that needs to be bisected. In * that case we will drop the through-the-end-of-time coverage via * `clearSyncedToDawnOfTime`. */ syncedToDawnOfTime: function() { if (!this.folderSyncer.canGrowSync) return true; var oldestSyncTS = this.getOldestFullSyncDate(); // We add a day to the oldest sync date to allow for some timezone-related // slop. This is done defensively. Unit tests ensure that our refresh of // synced-to-the-dawn-of-time does not result in date drift that would cause // the date to slowly move in and escape the slop. return ON_OR_BEFORE(oldestSyncTS, $sync.OLDEST_SYNC_DATE + $date.DAY_MILLIS); }, /** * Tally and return the number of messages we believe to exist in the folder. */ getKnownMessageCount: function() { var count = 0; for (var i = 0; i < this._headerBlockInfos.length; i++) { var blockInfo = this._headerBlockInfos[i]; count += blockInfo.count; } return count; }, /** * Retrieve the (ordered list) of messages covering a given IMAP-style date * range that we know about. Use `getMessagesBeforeMessage` or * `getMessagesAfterMessage` to perform iteration relative to a known * message. * * @args[ * @param[startTS DateMS]{ * SINCE-evaluated start timestamp (inclusive). * } * @param[endTS DateMS]{ * BEFORE-evaluated end timestamp (exclusive). If endTS is null, get all * messages since startTS. * } * @param[minDesired #:optional Number]{ * The minimum number of messages to return. We will keep loading blocks * from disk until this limit is reached. * } * @param[maxDesired #:optional Number]{ * The maximum number of messages to return. If there are extra messages * available in a header block after satisfying `minDesired`, we will * return them up to this limit. * } * @param[messageCallback @func[ * @args[ * @param[headers @listof[HeaderInfo]] * @param[moreMessagesComing Boolean]] * ] * ] * ] */ getMessagesInImapDateRange: function ifs_getMessagesInDateRange( startTS, endTS, minDesired, maxDesired, messageCallback) { var toFill = (minDesired != null) ? minDesired : $sync.TOO_MANY_MESSAGES, maxFill = (maxDesired != null) ? maxDesired : $sync.TOO_MANY_MESSAGES, self = this, // header block info iteration iHeadBlockInfo = null, headBlockInfo; // find the first header block with the data we want var headerPair = this._findFirstObjIndexForDateRange( this._headerBlockInfos, startTS, endTS); iHeadBlockInfo = headerPair[0]; headBlockInfo = headerPair[1]; if (!headBlockInfo) { // no blocks equals no messages. messageCallback([], false); return; } function fetchMore() { while (true) { // - load the header block if required if (!self._headerBlocks.hasOwnProperty(headBlockInfo.blockId)) { self._loadBlock('header', headBlockInfo, fetchMore); return; } var headerBlock = self._headerBlocks[headBlockInfo.blockId]; // - use up as many headers in the block as possible // (previously used destructuring, but we want uglifyjs to work) var headerTuple = self._findFirstObjForDateRange( headerBlock.headers, startTS, endTS), iFirstHeader = headerTuple[0], header = headerTuple[1]; // aw man, no usable messages?! if (!header) { messageCallback([], false); return; } // (at least one usable message) var iHeader = iFirstHeader; for (; iHeader < headerBlock.headers.length && maxFill; iHeader++, maxFill--) { header = headerBlock.headers[iHeader]; // (we are done if we have found a header earlier than what we want) if (BEFORE(header.date, startTS)) break; } // (iHeader is pointing at the index of message we don't want) // There is no further processing to do if we bailed early. if (maxFill && iHeader < headerBlock.headers.length) toFill = 0; else toFill -= iHeader - iFirstHeader; if (!toFill) { } // - There may be viable messages in the next block, check. else if (++iHeadBlockInfo >= self._headerBlockInfos.length) { // Nope, there are no more messages, nothing left to do. toFill = 0; } else { headBlockInfo = self._headerBlockInfos[iHeadBlockInfo]; // We may not want to go back any farther if (STRICTLY_AFTER(startTS, headBlockInfo.endTS)) toFill = 0; } // generate the notifications fo what we did create messageCallback(headerBlock.headers.slice(iFirstHeader, iHeader), Boolean(toFill)); if (!toFill) return; // (there must be some overlap, keep going) } } fetchMore(); }, /** * Batch/non-streaming version of `getMessagesInDateRange` using an IMAP * style date-range for syncing. * * @args[ * @param[allCallback @func[ * @args[ * @param[headers @listof[HeaderInfo]] * ] * ] * ] */ getAllMessagesInImapDateRange: function ifs_getAllMessagesInDateRange( startTS, endTS, allCallback) { var allHeaders = null; function someMessages(headers, moreHeadersExpected) { if (allHeaders) allHeaders = allHeaders.concat(headers); else allHeaders = headers; if (!moreHeadersExpected) allCallback(allHeaders); } this.getMessagesInImapDateRange(startTS, endTS, null, null, someMessages); }, /** * Fetch up to `limit` messages chronologically before the given message * (in the direction of 'start'). * * If date/id do not point to a valid message, return messages as * though it did point to a valid message (i.e. return messages past * that point, as you would probably expect). * * If date/id are null, it as if the date/id of the most recent message * are passed. */ getMessagesBeforeMessage: function(date, id, limit, messageCallback) { var toFill = (limit != null) ? limit : $sync.TOO_MANY_MESSAGES, self = this; var headerPair, iHeadBlockInfo, headBlockInfo; if (date) { headerPair = this._findRangeObjIndexForDateAndID( this._headerBlockInfos, date, id); iHeadBlockInfo = headerPair[0]; headBlockInfo = headerPair[1]; } else { iHeadBlockInfo = 0; headBlockInfo = this._headerBlockInfos[0]; } if (!headBlockInfo) { // headBlockInfo will be null if this date/id pair does not fit // properly into a block, but iHeadBlockInfo will still point to // a location from which we can start looking, and that leads us // to one of two cases: Either iHeadBlockInfo points to a valid // block (the one immediately after this point), in which case // we can just pretend that our targeted date/id resides // immediately futureward of the current block; or we've reached // the complete end of all blocks and iHeadBlockInfo points past // the end of headerBlockInfos, indicating that there are no // more messages pastward of our requested point. if (iHeadBlockInfo < this._headerBlockInfos.length) { // Search in this block. headBlockInfo = this._headerBlockInfos[iHeadBlockInfo]; } else { // If this message is older than all the existing blocks, // there aren't any messages to return, period, since we're // seeking pastward. messageCallback([], false); return; } } var iHeader = null; function fetchMore() { while (true) { // - load the header block if required if (!self._headerBlocks.hasOwnProperty(headBlockInfo.blockId)) { self._loadBlock('header', headBlockInfo, fetchMore); return; } var headerBlock = self._headerBlocks[headBlockInfo.blockId]; // Null means find it by id... if (iHeader === null) { if (id != null) { iHeader = bsearchForInsert(headerBlock.headers, { date: date, id: id }, cmpHeaderYoungToOld); if (headerBlock.ids[iHeader] === id) { // If we landed exactly on the message we were searching // for, we must skip _past_ it, as this method is not // intended to return this message, but only ones past it. iHeader++; } else { // If we didn't land on the exact header we sought, we // can just start returning results from iHeader onward. // since iHeader points to a message immediately beyond // the message we sought. } } else { // If we didn't specify an id to search for, we're // supposed to pretend that the first message in the block // was the one we wanted; in that case, start from index 1. iHeader = 1; } } // otherwise we know we are starting at the front of the block. else { iHeader = 0; } var useHeaders = Math.min( headerBlock.headers.length - iHeader, toFill); if (iHeader >= headerBlock.headers.length) useHeaders = 0; toFill -= useHeaders; // If there's nothing more to... if (!toFill) { } // - There may be viable messages in the next block, check. else if (++iHeadBlockInfo >= self._headerBlockInfos.length) { // Nope, there are no more messages, nothing left to do. toFill = 0; } else { headBlockInfo = self._headerBlockInfos[iHeadBlockInfo]; } // generate the notifications for what we did create messageCallback(headerBlock.headers.slice(iHeader, iHeader + useHeaders), Boolean(toFill)); if (!toFill) return; // (there must be some overlap, keep going) } } fetchMore(); }, /** * Fetch up to `limit` messages chronologically after the given message (in * the direction of 'end'). * * NOTE: Unlike getMessagesBeforeMessage, this method currently * expects date/id to point to a valid message, otherwise we'll * raise a badIterationStart error. */ getMessagesAfterMessage: function(date, id, limit, messageCallback) { var toFill = (limit != null) ? limit : $sync.TOO_MANY_MESSAGES, self = this; var headerPair = this._findRangeObjIndexForDateAndID( this._headerBlockInfos, date, id); var iHeadBlockInfo = headerPair[0]; var headBlockInfo = headerPair[1]; var scope = logic.subscope(this, { date: date, id: id }); if (!headBlockInfo) { // The iteration request is somehow not current; log an error and return // an empty result set. logic(scope, 'badIterationStart'); messageCallback([], false); return; } var iHeader = null; function fetchMore() { while (true) { // - load the header block if required if (!self._headerBlocks.hasOwnProperty(headBlockInfo.blockId)) { self._loadBlock('header', headBlockInfo, fetchMore); return; } var headerBlock = self._headerBlocks[headBlockInfo.blockId]; // Null means find it by id... if (iHeader === null) { iHeader = headerBlock.ids.indexOf(id); if (iHeader === -1) { logic(scope, 'badIterationStart'); toFill = 0; } iHeader--; } // otherwise we know we are starting at the end of the block (and // moving towards the front) else { iHeader = headerBlock.headers.length - 1; } var useHeaders = Math.min(iHeader + 1, toFill); if (iHeader < 0) useHeaders = 0; toFill -= useHeaders; // If there's nothing more to... if (!toFill) { } // - There may be viable messages in the previous block, check. else if (--iHeadBlockInfo < 0) { // Nope, there are no more messages, nothing left to do. toFill = 0; } else { headBlockInfo = self._headerBlockInfos[iHeadBlockInfo]; } // generate the notifications for what we did create var messages = headerBlock.headers.slice(iHeader - useHeaders + 1, iHeader + 1); messageCallback(messages, Boolean(toFill)); if (!toFill) return; // (there must be some overlap, keep going) } } fetchMore(); }, /** * Mark a given time range as synchronized. Timestamps are currently UTC * day-quantized values that indicate the day range that we have fully * synchronized with the server. The actual time-range of the synchronized * messages will be offset by the effective timezone of the server. * * To re-state in another way: if you take these timestamps and represent them * in UTC-0, that's the date we talk to the IMAP server with in terms of SINCE * and BEFORE. * * Note: I did consider doing timezones the right way where we would compute * things in the time-zone of the server. The problem with that is that our * timezone for the server is just a guess and the server's timezone can * actually change. And if the timezone changes, then all the dates would end * up shifted by a day when quantized, which is distinctly not what we want to * happen. * * @args[ * @param[startTS DateMS] * @param[endTS DateMS] * @param[modseq] * @param[updated DateMS] * ] */ markSyncRange: function(startTS, endTS, modseq, updated) { // If our range was marked open-ended, it's really accurate through now. // But we don't want true UTC now, we want the now of the server in terms of // IMAP's crazy SINCE/BEFORE quantized date-range. If it's already tomorrow // as far as the server is concerned date-wise, then we need to reflect that // here. // // To really spell it out, let's say that it's currently daylight savings // time, we live on the east coast (utc-4), and our server is in Europe // (utc+2). // // Let's say it's 7pm, which is 11pm at utc-0 and 1am at utc+2. NOW() is // going to net us the 11pm value; we need to add the timezone offset of // +2 to get to 1am, which is then what we want to use for markSyncRange. // if (!endTS) endTS = NOW(); if (startTS > endTS) throw new Error('Your timestamps are switched!'); var aranges = this._accuracyRanges; function makeRange(start, end, modseq, updated) { return { startTS: start, endTS: end, // let an existing fullSync be passed in instead... fullSync: (typeof(modseq) === 'string') ? { highestModseq: modseq, updated: updated } : { highestModseq: modseq.fullSync.highestModseq, updated: modseq.fullSync.updated }, }; } var newInfo = this._findFirstObjIndexForDateRange(aranges, startTS, endTS), oldInfo = this._findLastObjIndexForDateRange(aranges, startTS, endTS), newSplits, oldSplits; // We need to split the new block if we overlap a block and our end range // is not 'outside' the range. newSplits = newInfo[1] && STRICTLY_AFTER(newInfo[1].endTS, endTS); // We need to split the old block if we overlap a block and our start range // is not 'outside' the range. oldSplits = oldInfo[1] && BEFORE(oldInfo[1].startTS, startTS); var insertions = [], delCount = oldInfo[0] - newInfo[0]; if (oldInfo[1]) delCount++; if (newSplits) { // should this just be an effective merge with our insertion? if (newInfo[1].fullSync && newInfo[1].fullSync.highestModseq === modseq && newInfo[1].fullSync.updated === updated) endTS = newInfo[1].endTS; else insertions.push(makeRange(endTS, newInfo[1].endTS, newInfo[1])); } insertions.push(makeRange(startTS, endTS, modseq, updated)); if (oldSplits) { // should this just be an effective merge with what we just inserted? if (oldInfo[1].fullSync && oldInfo[1].fullSync.highestModseq === modseq && oldInfo[1].fullSync.updated === updated) insertions[insertions.length-1].startTS = oldInfo[1].startTS; else insertions.push(makeRange(oldInfo[1].startTS, startTS, oldInfo[1])); } // - merges // Consider a merge if there is an adjacent accuracy range in the given dir. var newNeighbor = newInfo[0] > 0 ? aranges[newInfo[0] - 1] : null, oldAdjust = oldInfo[1] ? 1 : 0, oldNeighbor = oldInfo[0] < (aranges.length - oldAdjust) ? aranges[oldInfo[0] + oldAdjust] : null; // We merge if our starts and ends line up... if (newNeighbor && insertions[0].endTS === newNeighbor.startTS && newNeighbor.fullSync && newNeighbor.fullSync.highestModseq === modseq && newNeighbor.fullSync.updated === updated) { insertions[0].endTS = newNeighbor.endTS; newInfo[0]--; delCount++; } if (oldNeighbor && insertions[insertions.length-1].startTS === oldNeighbor.endTS && oldNeighbor.fullSync && oldNeighbor.fullSync.highestModseq === modseq && oldNeighbor.fullSync.updated === updated) { insertions[insertions.length-1].startTS = oldNeighbor.startTS; delCount++; } aranges.splice.apply(aranges, [newInfo[0], delCount].concat(insertions)); /*lastSyncedAt depends on current timestamp of the client device should not be added timezone offset*/ this.folderMeta.lastSyncedAt = NOW(); this._dirty = true; }, /** * Mark that the most recent sync is believed to have synced all the messages * in the folder. For ActiveSync, this always happens and is effectively * meaningless; it's only an artifact of previous hacks that it calls this at * all. For IMAP, this is an inference that depends on us being up-to-date * with the rest of the folder. However it is also a self-correcting * inference since it causes our refreshes to include that time range since we * believe it to be safely empty. */ markSyncedToDawnOfTime: function() { logic(this, 'syncedToDawnOfTime'); // We can just expand the first accuracy range structure to stretch to the // dawn of time and nuke the rest. var aranges = this._accuracyRanges; // (If aranges is the empty list, there are deep invariant problems and // the exception is desired.) aranges[aranges.length - 1].startTS = $sync.OLDEST_SYNC_DATE; /*lastSyncedAt depends on current timestamp of the client device should not be added timezone offset*/ this.folderMeta.lastSyncedAt = NOW(); this._dirty = true; }, /** * Clear our indication that we have synced the entire folder through the dawn * of time, truncating the time coverage of the oldest accuracy range or * dropping it entirely. It is assumed/required that a call to markSyncRange * will follow this call within the same transaction, so the key thing is that * we lose the dawn-of-time bit without throwing away useful endTS values. */ clearSyncedToDawnOfTime: function(newOldestTS) { var aranges = this._accuracyRanges; if (!aranges.length) return; var lastRange = aranges[aranges.length - 1]; // Only update the startTS if it leaves a valid accuracy range if (STRICTLY_AFTER(lastRange.endTS, newOldestTS)) { lastRange.startTS = newOldestTS; } // Otherwise, pop the range to get rid of the info. This is a defensive // programming thing; we do not expect this case to happen, so we log. else { logic(this, 'accuracyRangeSuspect', { lastRange: lastRange }); aranges.pop(); } }, /** * Given a time range, check if we have fully-synchronized data covering * that range or part of that range. Return the smallest possible single * range covering all areas that are unsynchronized or were not synchronized * recently enough. * * We only return one range, so in the case we have valid data for Tuesday to * Thursday but the requested range is Monday to Friday, we still have to * return Monday to Friday because 1 range can't capture Monday to Monday and * Friday to Friday at the same time. * * @args[ * @param[startTS DateMS]{ * Inclusive range start. * } * @param[endTS DateMS]{ * Exclusive range start; consistent with accuracy range rep. * } * @param[threshMS Number]{ * The number of milliseconds to use as the threshold value for * determining if a time-range is recent enough. * } * ] * @return[@oneof[ * @case[null]{ * Everything is sufficiently up-to-date. No refresh required. * } * @case[@dict[ * @key[startTS DateMS]{ * Inclusive start date. * } * @key[endTS DateMS]{ * Exclusive end date. * } * ]] * ]] */ checkAccuracyCoverageNeedingRefresh: function(startTS, endTS, threshMS) { var aranges = this._accuracyRanges, arange, newInfo = this._findFirstObjIndexForDateRange(aranges, startTS, endTS), oldInfo = this._findLastObjIndexForDateRange(aranges, startTS, endTS), recencyCutoff = NOW() - threshMS; var result = { startTS: startTS, endTS: endTS }; if (newInfo[1]) { // - iterate from the 'end', trying to push things as far as we can go. var i; for (i = newInfo[0]; i <= oldInfo[0]; i++) { arange = aranges[i]; // skip out if this range would cause a gap (will not happen in base // case.) if (BEFORE(arange.endTS, result.endTS)) break; // skip out if this range was not fully updated or the data is too old if (!arange.fullSync || BEFORE(arange.fullSync.updated, recencyCutoff)) break; // if the range covers all of us or further than we need, we are done. if (ON_OR_BEFORE(arange.startTS, result.startTS)) return null; // the range only partially covers us; shrink our range and keep going result.endTS = arange.startTS; } // - iterate from the 'start', trying to push things as far as we can go. // (if we are here, we must not completely cover the range.) for (i = oldInfo[0]; i >= 0; i--) { arange = aranges[i]; // skip out if this range would cause a gap if (STRICTLY_AFTER(arange.startTS, result.startTS)) break; // skip out if this range was not fully updated or the data is too old if (!arange.fullSync || BEFORE(arange.fullSync.updated, recencyCutoff)) break; // the range only partially covers us; shrink our range and keep going result.startTS = arange.endTS; } } return result; }, /** * Retrieve a full message (header/body) by suid & date. If either the body or * header is not present res will be null. * * folderStorage.getMessage(suid, date, function(res) { * if (!res) { * // don't do anything * } * * res.header; * res.body; * }); * */ getMessage: function(suid, date, options, callback) { if (typeof(options) === 'function') { callback = options; options = undefined; } var header; var body; var pending = 2; function next() { if (!--pending) { if (!body || !header) { return callback(null); } callback({ header: header, body: body }); } } this.getMessageHeader(suid, date, function(_header) { header = _header; next(); }); var gotBody = function gotBody(_body) { body = _body; next(); }; if (options && options.withBodyReps) { this.getMessageBodyWithReps(suid, date, gotBody); } else { this.getMessageBody(suid, date, gotBody); } }, /** * Retrieve a message header by its SUID and date; you would do this if you * only had the SUID and date, like in a 'job'. */ getMessageHeader: function ifs_getMessageHeader(suid, date, callback) { var id = parseInt(suid.substring(suid.lastIndexOf('/') + 1)), posInfo = this._findRangeObjIndexForDateAndID(this._headerBlockInfos, date, id); if (posInfo[1] === null) { logic(this, 'headerNotFound'); try { callback(null); } catch (ex) { logic(this, 'callbackErr', { ex: ex }); } return; } var headerBlockInfo = posInfo[1], self = this; if (!(this._headerBlocks.hasOwnProperty(headerBlockInfo.blockId))) { this._loadBlock('header', headerBlockInfo, function(headerBlock) { var idx = headerBlock.ids.indexOf(id); var headerInfo = headerBlock.headers[idx] || null; if (!headerInfo) logic(self, 'headerNotFound'); try { callback(headerInfo); } catch (ex) { logic(self, 'callbackErr', { ex: ex }); } }); return; } var block = this._headerBlocks[headerBlockInfo.blockId], idx = block.ids.indexOf(id), headerInfo = block.headers[idx] || null; if (!headerInfo) logic(this, 'headerNotFound'); try { callback(headerInfo); } catch (ex) { logic(this, 'callbackErr', { ex: ex }); } }, /** * Retrieve multiple message headers. */ getMessageHeaders: function ifs_getMessageHeaders(namers, callback) { var pending = namers.length; var headers = []; var gotHeader = function gotHeader(header) { if (header) { headers.push(header); } if (!--pending) { callback(headers); } }; for (var i = 0; i < namers.length; i++) { var namer = namers[i]; this.getMessageHeader(namer.suid, namer.date, gotHeader); } }, /** * Add a new message to the database, generating slice notifications. * * @param header * @param [body] * Optional body, exists to hint to slices so that SearchFilter can peek * directly at the body without needing to make an additional request to * look at the body. */ addMessageHeader: function ifs_addMessageHeader(header, body, callback) { if (header.id == null || header.suid == null) { throw new Error('No valid id: ' + header.id + ' or suid: ' + header.suid); } if (this._pendingLoads.length) { this._deferredCalls.push(this.addMessageHeader.bind( this, header, body, callback)); return; } if (header.flags && header.flags.indexOf('\\Seen') === -1) { this.folderMeta.unreadCount++; } logic(this, 'addMessageHeader', { date: header.date, id: header.id, srvid: header.srvid }); this.headerCount += 1; if (this._curSyncSlice) { // TODO: make sure the slice knows the true offset of its // first header in the folder. Currently the UI never // shrinks its slice so this number is always 0 and we can // get away without providing that offset for now. this._curSyncSlice.headerCount = this.headerCount; if (!this._curSyncSlice.ignoreHeaders) { this._curSyncSlice.onHeaderAdded(header, body, true, true); } } // - Generate notifications for (other) interested slices if (this._slices.length > (this._curSyncSlice ? 1 : 0)) { var date = header.date, uid = header.id; for (var iSlice = 0; iSlice < this._slices.length; iSlice++) { var slice = this._slices[iSlice]; if (slice === this._curSyncSlice) { continue; } if (slice.type === 'folder') { // TODO: make sure the slice knows the true offset of its // first header in the folder. Currently the UI never // shrinks its slice so this number is always 0 and we can // get away without providing that offset for now. slice.headerCount = this.headerCount; } // Note: the following control flow is to decide when to bail; if we // make it through the conditionals, the header gets reported to the // slice. // (if the slice is empty, it cares about any header, so keep going) if (slice.startTS !== null) { // We never automatically grow a slice into the past if we are full, // but we do allow it if not full. if (BEFORE(date, slice.startTS)) { if (slice.headers.length >= slice.desiredHeaders) { continue; } } // We do grow a slice into the present if it's already up-to-date. // We do count messages from the same second as our else if (SINCE(date, slice.endTS)) { // !(covers most recently known message) if(!(this._headerBlockInfos.length && slice.endTS === this._headerBlockInfos[0].endTS && slice.endUID === this._headerBlockInfos[0].endUID)) continue; } else if ((date === slice.startTS && uid < slice.startUID) || (date === slice.endTS && uid > slice.endUID)) { continue; } } else { // Make sure to increase the number of desired headers so the // truncating heuristic won't rule the header out. slice.desiredHeaders++; } if (slice._onAddingHeader) { try { slice._onAddingHeader(header); } catch (ex) { logic(this, 'callbackErr', { ex: ex }); } } try { slice.onHeaderAdded(header, body, false, true); } catch (ex) { logic(this, 'callbackErr', { ex: ex }); } } } this._insertIntoBlockUsingDateAndUID( 'header', header.date, header.id, header.srvid, $sync.HEADER_EST_SIZE_IN_BYTES, header, callback); }, /** * Update an existing mesage header in the database, generating slice * notifications and dirtying its containing block to cause eventual database * writeback. * * A message header gets updated ONLY because of a change in its flags. We * don't consider this change large enough to cause us to need to split a * block. * * This function can either be used to replace the header or to look it up * and then call a function to manipulate the header. * * Options: * { silent: true } * Don't send slice updates. Used when updating an internal * IMAP-specific flag (imapMissingInSyncRange: slices don't need * to know about it) so that existing tests don't get mad that * we're sending out extra updateMessageHeader events without * expecting them. This flag should be removed in the test * refactoring to allow more fine-grained control over * onHeaderModified assertions. */ updateMessageHeader: function ifs_updateMessageHeader(date, id, partOfSync, headerOrMutationFunc, body, callback, opts) { // (While this method can complete synchronously, we want to maintain its // perceived ordering relative to those that cannot be.) if (this._pendingLoads.length) { this._deferredCalls.push(this.updateMessageHeader.bind( this, date, id, partOfSync, headerOrMutationFunc, body, callback)); return; } // We need to deal with the potential for the block having been discarded // from memory thanks to the potential asynchrony due to pending loads or // on the part of the caller. var infoTuple = this._findRangeObjIndexForDateAndID( this._headerBlockInfos, date, id), iInfo = infoTuple[0], info = infoTuple[1], self = this; function doUpdateHeader(block) { var idx = block.ids.indexOf(id), header; if (idx === -1) { // Call the mutation func with null to let it know we couldn't find the // header. if (headerOrMutationFunc instanceof Function) headerOrMutationFunc(null); else throw new Error('Failed to find ID ' + id + '!'); } else if (headerOrMutationFunc instanceof Function) { // If it returns false it means that the header did not change and so // there is no need to mark anything dirty and we can leave without // notifying anyone. if (!headerOrMutationFunc((header = block.headers[idx]))) header = null; } else { header = block.headers[idx] = headerOrMutationFunc; } // only dirty us and generate notifications if there is a header if (header) { self._dirty = true; self._dirtyHeaderBlocks[info.blockId] = block; logic(self, 'updateMessageHeader', { date: header.date, id: header.id, srvid: header.srvid }); if (self._slices.length > (self._curSyncSlice ? 1 : 0)) { for (var iSlice = 0; iSlice < self._slices.length; iSlice++) { var slice = self._slices[iSlice]; if (partOfSync && slice === self._curSyncSlice) continue; if (opts && opts.silent) { continue; } if (BEFORE(date, slice.startTS) || STRICTLY_AFTER(date, slice.endTS)) continue; if ((date === slice.startTS && id < slice.startUID) || (date === slice.endTS && id > slice.endUID)) continue; try { slice.onHeaderModified(header, body); } catch (ex) { logic(this, 'callbackErr', { ex: ex }); } } } } if (callback) callback(); } if (!info) { if (headerOrMutationFunc instanceof Function) headerOrMutationFunc(null); else throw new Error('Failed to find block containing header with date: ' + date + ' id: ' + id); } else if (!this._headerBlocks.hasOwnProperty(info.blockId)) this._loadBlock('header', info, doUpdateHeader); else doUpdateHeader(this._headerBlocks[info.blockId]); }, /** * Retrieve and update a header by locating it */ updateMessageHeaderByServerId: function(srvid, partOfSync, headerOrMutationFunc, body, callback) { if (this._pendingLoads.length) { this._deferredCalls.push(this.updateMessageHeaderByServerId.bind( this, srvid, partOfSync, headerOrMutationFunc, body, callback)); return; } var blockId = this._serverIdHeaderBlockMapping[srvid]; if (srvid === undefined) { logic(this, 'serverIdMappingMissing', { srvid: srvid }); return; } var findInBlock = function findInBlock(headerBlock) { var headers = headerBlock.headers; for (var i = 0; i < headers.length; i++) { var header = headers[i]; if (header.srvid === srvid) { // future work: this method will duplicate some work to re-locate // the header; we could try and avoid doing that. this.updateMessageHeader( header.date, header.id, partOfSync, headerOrMutationFunc, body, callback); return; } } }.bind(this); if (this._headerBlocks.hasOwnProperty(blockId)) { findInBlock(this._headerBlocks[blockId]); } else { var blockInfo = this._findBlockInfoFromBlockId('header', blockId); this._loadBlock('header', blockInfo, findInBlock); } }, /** * A notification that an existing header is still up-to-date. */ unchangedMessageHeader: function ifs_unchangedMessageHeader(header) { if (this._pendingLoads.length) { this._deferredCalls.push(this.unchangedMessageHeader.bind(this, header)); return; } // (no block update required) if (this._curSyncSlice && !this._curSyncSlice.ignoreHeaders) this._curSyncSlice.onHeaderAdded(header, true, false); }, hasMessageWithServerId: function(srvid) { if (!this._serverIdHeaderBlockMapping) throw new Error('Server ID mapping not supported for this storage!'); var blockId = this._serverIdHeaderBlockMapping[srvid]; if (srvid === undefined) { logic(this, 'serverIdMappingMissing', { srvid: srvid }); return false; } return !!blockId; }, deleteMessageHeaderAndBody: function(suid, date, callback) { this.getMessageHeader(suid, date, function(header) { if (header) this.deleteMessageHeaderAndBodyUsingHeader(header, callback); else callback(); }.bind(this)); }, deleteMessageHeaderUsingHeader: function(header, callback) { if (this._pendingLoads.length) { this._deferredCalls.push(this.deleteMessageHeaderUsingHeader.bind( this, header, callback)); return; } this.headerCount -= 1; if (this._curSyncSlice) { // TODO: make sure the slice knows the true offset of its // first header in the folder. Currently the UI never // shrinks its slice so this number is always 0 and we can // get away without providing that offset for now. this._curSyncSlice.headerCount = this.headerCount; // NB: ignoreHeaders should never be true if we are deleting headers, but // just doing this as a simple transform for equivalence purposes. // ignoreHeaders should go away. if (!this._curSyncSlice.ignoreHeaders) { this._curSyncSlice.onHeaderRemoved(header); } } if (this._slices.length > (this._curSyncSlice ? 1 : 0)) { for (var iSlice = 0; iSlice < this._slices.length; iSlice++) { var slice = this._slices[iSlice]; if (slice.type === 'folder') { // TODO: make sure the slice knows the true offset of its // first header in the folder. Currently the UI never // shrinks its slice so this number is always 0 and we can // get away without providing that offset for now. slice.headerCount = this.headerCount; } if (slice === this._curSyncSlice) continue; if (BEFORE(header.date, slice.startTS) || STRICTLY_AFTER(header.date, slice.endTS)) continue; if ((header.date === slice.startTS && header.id < slice.startUID) || (header.date === slice.endTS && header.id > slice.endUID)) continue; slice.onHeaderRemoved(header); } } if (this._serverIdHeaderBlockMapping && header.srvid) delete this._serverIdHeaderBlockMapping[header.srvid]; this._deleteFromBlock('header', header.date, header.id, callback); }, deleteMessageHeaderAndBodyUsingHeader: function(header, callback) { if (this._pendingLoads.length) { this._deferredCalls.push(this.deleteMessageHeaderAndBodyUsingHeader.bind( this, header, callback)); return; } this.deleteMessageHeaderUsingHeader(header, function() { this._deleteFromBlock('body', header.date, header.id, callback); }.bind(this)); }, /** * Delete a message header and its body using only the server id for the * message. This requires that `serverIdHeaderBlockMapping` was enabled. * Currently, the mapping is a naive, always-in-memory (at least as long as * the FolderStorage is in memory) map. */ deleteMessageByServerId: function(srvid, callback) { if (!this._serverIdHeaderBlockMapping) throw new Error('Server ID mapping not supported for this storage!'); if (this._pendingLoads.length) { this._deferredCalls.push(this.deleteMessageByServerId.bind(this, srvid, callback)); return; } var blockId = this._serverIdHeaderBlockMapping[srvid]; if (srvid === undefined) { logic(this, 'serverIdMappingMissing', { srvid: srvid }); return; } var findInBlock = function findInBlock(headerBlock) { var headers = headerBlock.headers; for (var i = 0; i < headers.length; i++) { var header = headers[i]; if (header.srvid === srvid) { this.deleteMessageHeaderAndBodyUsingHeader(header, callback); return; } } }.bind(this); if (this._headerBlocks.hasOwnProperty(blockId)) { findInBlock(this._headerBlocks[blockId]); } else { var blockInfo = this._findBlockInfoFromBlockId('header', blockId); this._loadBlock('header', blockInfo, findInBlock); } }, /** * Add a message body to the system; you must provide the header associated * with the body. */ addMessageBody: function ifs_addMessageBody(header, bodyInfo, callback) { if (this._pendingLoads.length) { this._deferredCalls.push(this.addMessageBody.bind( this, header, bodyInfo, callback)); return; } logic(this, 'addMessageBody', { date: header.date, id: header.id, srvid: header.srvid, bodyInfo: bodyInfo }); // crappy size estimates where we assume the world is ASCII and so a UTF-8 // encoding will take exactly 1 byte per character. var sizeEst = OBJ_OVERHEAD_EST + NUM_ATTR_OVERHEAD_EST + 4 * NULL_ATTR_OVERHEAD_EST; function sizifyAddrs(addrs) { sizeEst += LIST_ATTR_OVERHEAD_EST; if (!addrs) return; for (var i = 0; i < addrs.length; i++) { var addrPair = addrs[i]; sizeEst += OBJ_OVERHEAD_EST + 2 * STR_ATTR_OVERHEAD_EST + (addrPair.name ? addrPair.name.length : 0) + (addrPair.address ? addrPair.address.length : 0); } } function sizifyAttachments(atts) { sizeEst += LIST_ATTR_OVERHEAD_EST; if (!atts) return; for (var i = 0; i < atts.length; i++) { var att = atts[i]; sizeEst += OBJ_OVERHEAD_EST + 2 * STR_ATTR_OVERHEAD_EST + att.name.length + att.type.length + NUM_ATTR_OVERHEAD_EST; } } function sizifyStr(str) { sizeEst += STR_ATTR_OVERHEAD_EST + str.length; } function sizifyStringList(strings) { sizeEst += LIST_OVERHEAD_EST; if (!strings) return; for (var i = 0; i < strings.length; i++) { sizeEst += STR_ATTR_OVERHEAD_EST + strings[i].length; } } function sizifyBodyRep(rep) { sizeEst += LIST_OVERHEAD_EST + NUM_OVERHEAD_EST * (rep.length / 2) + STR_OVERHEAD_EST * (rep.length / 2); for (var i = 1; i < rep.length; i += 2) { if (rep[i]) sizeEst += rep[i].length; } }; function sizifyBodyReps(reps) { if (!reps) return; sizeEst += STR_OVERHEAD_EST * (reps.length / 2); for (var i = 0; i < reps.length; i++) { var rep = reps[i]; if (rep.type === 'html') { sizeEst += STR_OVERHEAD_EST + rep.amountDownloaded; } else { rep.content && sizifyBodyRep(rep.content); } } }; if (bodyInfo.to) sizifyAddrs(bodyInfo.to); if (bodyInfo.cc) sizifyAddrs(bodyInfo.cc); if (bodyInfo.bcc) sizifyAddrs(bodyInfo.bcc); if (bodyInfo.replyTo) sizifyStr(bodyInfo.replyTo); sizifyAttachments(bodyInfo.attachments); sizifyAttachments(bodyInfo.relatedParts); sizifyStringList(bodyInfo.references); sizifyBodyReps(bodyInfo.bodyReps); bodyInfo.size = sizeEst; this._insertIntoBlockUsingDateAndUID( 'body', header.date, header.id, header.srvid, bodyInfo.size, bodyInfo, callback); }, /** * Determines if the bodyReps of a given body have been downloaded... * * Note that for POP3 we will return false here if there are undownloaded * attachments even if the body parts are entirely downloaded. This * situation would occur if the body is extremely small and so our snippet * fetch is able to fully retrieve the observed body parts. * * storage.messageBodyRepsDownloaded(bodyInfo) => true/false * */ messageBodyRepsDownloaded: function(bodyInfo) { // no reps its as close to downloaded as its going to get. if (!bodyInfo.bodyReps || !bodyInfo.bodyReps.length) return true; var bodyRepsDownloaded = bodyInfo.bodyReps.every(function(rep) { return rep.isDownloaded; }); // As noted above, for POP3 we want to also validate the state of the // attachments since they need to be downloaded for the whole message to // have been downloaded. Of course, we only want to do this for the inbox; // all other folders are synthetic and downloading is nonsensical. // // Sarcastic hooray for POP3 forcing us to do stuff like this. if (this._account.type !== 'pop3' || this.folderMeta.type !== 'inbox') { return bodyRepsDownloaded; } var attachmentsDownloaded = bodyInfo.attachments.every(function(att) { return !!att.file; }); return bodyRepsDownloaded && attachmentsDownloaded; }, /** * Identical to getMessageBody but will attempt to download all body reps * prior to firing its callback . */ getMessageBodyWithReps: function(suid, date, callback) { var self = this; // try to get the body without any magic this.getMessageBody(suid, date, function(bodyInfo) { if (!bodyInfo) { return callback(bodyInfo); } if (self.messageBodyRepsDownloaded(bodyInfo)) { return callback(bodyInfo); } // queue a job and return bodyInfo after it completes.. self._account.universe.downloadMessageBodyReps(suid, date, function(err, bodyInfo) { // the err (if any) will be logged by the job. callback(bodyInfo); }); }); }, /** * Load the given message body while obeying call ordering consistency rules. * If any other calls have gone asynchronous because block loads are required, * then this call will wait for those calls to complete first even if we * already have the requested body block loaded. If we haven't gone async and * the body is already available, the callback will be invoked synchronously * while this function is still on the stack. So, uh, don't be surprised by * that. */ getMessageBody: function ifs_getMessageBody(suid, date, callback) { if (this._pendingLoads.length) { this._deferredCalls.push( this.getMessageBody.bind(this, suid, date, callback)); return; } var id = parseInt(suid.substring(suid.lastIndexOf('/') + 1)), posInfo = this._findRangeObjIndexForDateAndID(this._bodyBlockInfos, date, id); if (posInfo[1] === null) { logic(this, 'bodyNotFound'); try { callback(null); } catch (ex) { logic(this, 'callbackErr', { ex: ex }); } return; } var bodyBlockInfo = posInfo[1], self = this; if (!(this._bodyBlocks.hasOwnProperty(bodyBlockInfo.blockId))) { this._loadBlock('body', bodyBlockInfo, function(bodyBlock) { var bodyInfo = bodyBlock.bodies[id] || null; if (!bodyInfo) logic(self, 'bodyNotFound'); try { callback(bodyInfo); } catch (ex) { logic(self, 'callbackErr', { ex: ex }); } }); return; } var block = this._bodyBlocks[bodyBlockInfo.blockId], bodyInfo = block.bodies[id] || null; if (!bodyInfo) logic(this, 'bodyNotFound'); try { callback(bodyInfo); } catch (ex) { logic(this, 'callbackErr', { ex: ex }); } }, /** * Update a message body; this should only happen because of attachments / * related parts being downloaded or purged from the system. This is an * asynchronous operation. * * Right now it is assumed/required that this body was retrieved via * getMessageBody while holding a mutex so that the body block must still * be around in memory. * * Additionally the final argument allows you to send an event to any client * listening for changes on a given body. * * // client listening for a body change event * * // ( body is a MessageBody ) * body.onchange = function(detail, bodyInfo) { * // detail => { changeDetails: { bodyReps: [0], ... }, value: y } * }; * * // in the backend * * storage.updateMessageBody( * header, * changedBodyInfo, * { changeDetails: { bodyReps: [0], ... }, value: y } * ); * * @method updateMessageBody * @param header {HeaderInfo} * @param bodyInfo {BodyInfo} * @param options {Object} * @param [options.flushBecause] {'blobs'} * If present, indicates that we should flush the message body to disk and * read it back from IndexedDB because we are writing Blobs that are not * already known to IndexedDB and we want to replace potentially * memory-backed Blobs with disk-backed Blobs. This is essential for * memory management. There are currently no extenuating circumstances * where you should lie to us about this. * * This inherently causes saveAccountState to be invoked, so callers should * sanity-check they aren't doing something weird to the database that could * cause a non-coherent state to appear. * * If you pass a value for this, you *must* forget your reference to the * bodyInfo you pass in in order for our garbage collection to work! * @param eventDetails {Object} * An event details object that describes the changes being made to the * body representation. This object will be directly reported to clients. * If omitted, no event will be generated. Only do this if you're doing * something that should not be made visible to anything; like while the * process of attaching * * Please be sure to document everything here for now. * @param eventDetails.changeDetails {Object} * An object indicating what changed in the body. All of the following * attributes are optional. If they aren't present, the thing didn't * change. * @param eventDetails.changeDetails.bodyReps {Number[]} * The indices of the bodyReps array that changed. In general bodyReps * should only be added or modified. However, in the case of POP3, a * fictitious body part of type 'fake' may be created and may subsequently * be removed. No index is generated for the removal, but this should * end up being okay because the UI should not reflect the 'fake' bodyRep * into anything. * @param eventDetails.changeDetails.attachments {Number[]} * The indices of the attachments array that changed by being added or * modified. Attachments may be detached; these indices are reported in * detachedAttachments. * @param eventDetails.changeDetails.relatedParts {Number[]} * The indices of the relatedParts array that changed by being added or * modified. * @param eventDetails.changeDetails.detachedAttachments {Number[]} * The indices of the attachments array that were deleted. Note that this * should only happen for drafts and no code should really be holding onto * those bodies. Additionally, the draft headers/bodies get nuked and * re-created every time a draft is saved, so they shouldn't hang around in * general. However, we really do not want to allow the Blob references to * leak, so we do report this so we can clean them up in clients. splices * for this case should be performed in the order reported. * @param callback {Function} * A callback to be invoked after the body has been updated and after any * body change notifications have been handed off to the MailUniverse. The * callback receives a reference to the updated BodyInfo object. */ updateMessageBody: function(header, bodyInfo, options, eventDetails, callback) { if (typeof(eventDetails) === 'function') { callback = eventDetails; eventDetails = null; } // (While this method can complete synchronously, we want to maintain its // perceived ordering relative to those that cannot be.) if (this._pendingLoads.length) { this._deferredCalls.push(this.updateMessageBody.bind( this, header, bodyInfo, options, eventDetails, callback)); return; } var suid = header.suid; var id = parseInt(suid.substring(suid.lastIndexOf('/') + 1)); var self = this; // (called when addMessageBody completes) function bodyUpdated() { if (options.flushBecause) { bodyInfo = null; self._account.saveAccountState( null, // no transaction to reuse function forgetAndReGetMessageBody() { // Force the block hosting the body to be discarded from the // cache. self.getMessageBody(suid, header.date, performNotifications); }, 'flushBody'); } else { performNotifications(); } } function performNotifications(refreshedBody) { if (refreshedBody) { bodyInfo = refreshedBody; } if (eventDetails && self._account.universe) { self._account.universe.__notifyModifiedBody( suid, eventDetails, bodyInfo ); } if (callback) { callback(bodyInfo); } } // We always recompute the size currently for safety reasons, but as of // writing this, changes to attachments/relatedParts will not affect the // body size, only changes to body reps. this._deleteFromBlock('body', header.date, id, function() { self.addMessageBody(header, bodyInfo, bodyUpdated); }); }, shutdown: function() { // reverse iterate since they will remove themselves as we kill them for (var i = this._slices.length - 1; i >= 0; i--) { this._slices[i].die(); } this.folderSyncer.shutdown(); }, /** * The folder is no longer known on the server or we are just deleting the * account; close out any live connections or processing. Database cleanup * will be handled at the account level so it can go in a transaction with * all the other related changes. */ youAreDeadCleanupAfterYourself: function() { // XXX close connections, etc. }, }; }); // end define