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

4719 lines
174 KiB
JavaScript
Executable file

/**
* 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