Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
1205 lines
44 KiB
JavaScript
Executable file
1205 lines
44 KiB
JavaScript
Executable file
/**
|
|
* Abstractions for dealing with the various mutation operations.
|
|
*
|
|
* NB: Moves discussion is speculative at this point; we are just thinking
|
|
* things through for architectural implications.
|
|
*
|
|
* == Speculative Operations ==
|
|
*
|
|
* We want our UI to update as soon after requesting an operation as possible.
|
|
* To this end, we have logic to locally apply queued mutation operations.
|
|
* Because we may want to undo operations when we are offline (and have not
|
|
* been able to talk to the server), we also need to be able to reflect these
|
|
* changes locally independent of telling the server.
|
|
*
|
|
* In the case of moves/copies, we issue a(n always locally created) id for the
|
|
* message immediately and just set the server UID (srvid) to 0 to be populated
|
|
* by the sync process.
|
|
*
|
|
* == Data Integrity ==
|
|
*
|
|
* Our strategy is always to avoid server data-loss, so data-destruction actions
|
|
* must always take place after successful confirmation of persistence actions.
|
|
* (Just keeping the data in-memory is not acceptable because we could crash,
|
|
* etc.)
|
|
*
|
|
* This is in contrast to our concern about losing simple, frequently performed
|
|
* idempotent user actions in a crash. We assume that A) crashes will be
|
|
* rare, B) the user will not be surprised or heart-broken if a message they
|
|
* marked read a second before a crash needs to manually be marked read after
|
|
* restarting the app/device, and C) there are performance/system costs to
|
|
* saving the state which makes this a reasonable trade-off.
|
|
*
|
|
* It is also our strategy to avoid cluttering up the place as a side-effect
|
|
* of half-done things. For example, if we are trying to move N messages,
|
|
* but only copy N/2 because of a timeout, we want to make sure that we
|
|
* don't naively retry and then copy those first N/2 messages a second time.
|
|
* This means that we track sub-steps explicitly, and that operations that we
|
|
* have issued and may or may not have been performed by the server will be
|
|
* checked before they are re-attempted. (Although IMAP batch operations
|
|
* are atomic, and our IndexedDB commits are atomic, they are atomic independent
|
|
* of each other and so we could have been notified that the copy completed
|
|
* but not persisted the fact to our database.)
|
|
*
|
|
* In the event we restore operations from disk that were enqueued but
|
|
* apparently not run, we compel them to run a check operation before they are
|
|
* performed because it's possible (depending on the case) for us to have run
|
|
* them without saving the account state first. This is a trade-off between the
|
|
* cost of checking and the cost of issuing commits to the database frequently
|
|
* based on the expected likelihood of a crash on our part. Per comments above,
|
|
* we expect crashes to be rare and not particularly correlated with operations,
|
|
* so it's better for the device (both flash and performance) if we don't
|
|
* continually checkpoint our state.
|
|
*
|
|
* All non-idempotent operations / operations that could result in data loss or
|
|
* duplication require that we save our account state listing the operation. In
|
|
* the event of a crash, this allows us to know that we have to check the state
|
|
* of the operation for completeness before attempting to run it again and
|
|
* allowing us to finish half-done things. For particular example, because
|
|
* moves consist of a copy followed by flagging a message deleted, it is of the
|
|
* utmost importance that we don't get in a situation where we have copied the
|
|
* messages but not deleted them and we crash. In that case, if we failed to
|
|
* persist our plans, we will have duplicated the message (and the IMAP server
|
|
* would have no reason to believe that was not our intent.)
|
|
**/
|
|
|
|
define(
|
|
[
|
|
'logic',
|
|
'mix',
|
|
'../jobmixins',
|
|
'../drafts/jobs',
|
|
'../allback',
|
|
'mimeparser',
|
|
'module',
|
|
'exports'
|
|
],
|
|
function(
|
|
logic,
|
|
mix,
|
|
$jobmixins,
|
|
draftsJobs,
|
|
allback,
|
|
MimeParser,
|
|
$module,
|
|
exports
|
|
) {
|
|
|
|
/**
|
|
* The evidence suggests the job has not yet been performed.
|
|
*/
|
|
var CHECKED_NOTYET = 'checked-notyet';
|
|
/**
|
|
* The operation is idempotent and atomic, just perform the operation again.
|
|
* No checking performed.
|
|
*/
|
|
var UNCHECKED_IDEMPOTENT = 'idempotent';
|
|
/**
|
|
* The evidence suggests that the job has already happened.
|
|
*/
|
|
var CHECKED_HAPPENED = 'happened';
|
|
/**
|
|
* The job is no longer relevant because some other sequence of events
|
|
* have mooted it. For example, we can't change tags on a deleted message
|
|
* or move a message between two folders if it's in neither folder.
|
|
*/
|
|
var CHECKED_MOOT = 'moot';
|
|
/**
|
|
* A transient error (from the checker's perspective) made it impossible to
|
|
* check.
|
|
*/
|
|
var UNCHECKED_BAILED = 'bailed';
|
|
/**
|
|
* The job has not yet been performed, and the evidence is that the job was
|
|
* not marked finished because our database commits are coherent. This is
|
|
* appropriate for retrieval of information, like the downloading of
|
|
* attachments.
|
|
*/
|
|
var UNCHECKED_COHERENT_NOTYET = 'coherent-notyet';
|
|
|
|
/**
|
|
* In the event the server is horrible and does not tell us delimiter info, we
|
|
* need to pick one. The horrible servers I've seen all seem to use '/', so
|
|
* let's just arbitrarily go with that.
|
|
*/
|
|
var FALLBACK_FOLDER_DELIM = '/';
|
|
|
|
/**
|
|
* Figure out the correct name for a folder given the parent folder and
|
|
* namespace information. Note that because there exist certain IMAP
|
|
* implementations out there that don't return a namespace delimiter, we
|
|
* may still need to fall back to FALLBACK_FOLDER_DELIM. In the future we
|
|
* can improve on this by looking at what other delimiters have been reported.
|
|
* (We have seen both null namespace delimiters and null INBOX delimiters.)
|
|
*
|
|
* We export this function so it can be unit-tested in isolation.
|
|
*
|
|
* @param {String} name
|
|
* The name of the folder we want to create. It does not need to be encoded;
|
|
* that will happen in browserbox.
|
|
* @param {Boolean} containOtherFolders
|
|
* Will this folder contain other folders? The result is for us to append
|
|
* the delimiter if true. The RFC 3501 semantics are that this should be
|
|
* passed when there will be child folders. The real-world horrors mean
|
|
* that this is most likely an issue on a server using a storage mechanism
|
|
* where you can only have a file or a subdirectory with the given name.
|
|
* In general we expect to pass false for this right now. When we support
|
|
* user folder creation, we'll need to do more of a survey here. (We might
|
|
* want to do a probe step when creating an account to figure out what the
|
|
* server does to know whether we need to bother the user, etc.)
|
|
* @param {FolderMeta} [parentFolderInfo]
|
|
* If we're creating this folder under another folder, the folderInfo.$meta
|
|
* for the folder. This value should be null if we want to create a
|
|
* "top-level" folder. Note that when the personal namespace is "INBOX."
|
|
* so that everything goes under the folder, passing a parentFolderInfo of
|
|
* INBOX or null should end up doing the same thing. (Even if we didn't
|
|
* properly normalize this, the server would probably do it for us anyways;
|
|
* dovecot does, at least.
|
|
*
|
|
* The parts of parentFolderInfo we use are just { path, delim, depth }.
|
|
* @param {Object} personalNamespace
|
|
* The personal namespace info which can be characterized as { prefix,
|
|
* delimiter}. We REQUIRE that the caller pass in an object here, although
|
|
* we do not require that the properties are present / non-null.
|
|
*
|
|
* @return
|
|
* An object of the form { path, delim, depth } where path is the path we
|
|
* derived, delim is what we think the effective delimiter character is at
|
|
* that point, and depth the depth of the folder.
|
|
*/
|
|
var deriveFolderPath = exports.deriveFolderPath =
|
|
function(name, containOtherFolders, parentFolderInfo, personalNamespace) {
|
|
var path, delim, depth;
|
|
// If we've got the parent folder info, we're doing pretty well but we
|
|
// still might need to failover the delimiter.
|
|
if (parentFolderInfo) {
|
|
// We have seen INBOX folders and namespaces lacking indicated delimiters.
|
|
delim = parentFolderInfo.delim ||
|
|
personalNamespace.delimiter ||
|
|
FALLBACK_FOLDER_DELIM;
|
|
path = parentFolderInfo.path || '';
|
|
depth = parentFolderInfo.depth + 1;
|
|
} else {
|
|
delim = personalNamespace.delimiter ||
|
|
FALLBACK_FOLDER_DELIM;
|
|
path = personalNamespace.prefix || '';
|
|
depth = path ? 1: 0;
|
|
}
|
|
|
|
// If we're going under something, we need the delimiter.
|
|
if (path) {
|
|
path += delim;
|
|
}
|
|
|
|
path += name;
|
|
|
|
if (containOtherFolders) {
|
|
path += delim;
|
|
}
|
|
|
|
return { path: path, delimiter: delim, depth: depth };
|
|
};
|
|
|
|
/**
|
|
* @typedef[MutationState @dict[
|
|
* @key[suidToServerId @dictof[
|
|
* @key[SUID]
|
|
* @value[ServerID]
|
|
* ]]{
|
|
* Tracks the server id (UID on IMAP) for an account as it is currently
|
|
* believed to exist on the server. We persist this because the actual
|
|
* header may have been locally moved to another location already, so
|
|
* there may not be storage for the information in the folder when
|
|
* subsequent non-local operations run (such as another move or adding
|
|
* a tag).
|
|
*
|
|
* This table is entirely populated by the actual (non-local) move
|
|
* operations. Entries remain in this table until they are mooted by a
|
|
* subsequent move or the table is cleared once all operations for the
|
|
* account complete.
|
|
* }
|
|
* @key[moveMap @dictof[
|
|
* @key[oldSuid SUID]
|
|
* @value[newSuid SUID]
|
|
* ]]{
|
|
* Expresses the relationship between moved messages by local-operations.
|
|
* }
|
|
* ]]
|
|
*
|
|
* @typedef[MutationStateDelta @dict[
|
|
* @key[serverIdMap @dictof[
|
|
* @key[suid SUID]
|
|
* @value[srvid @oneof[null ServerID]]
|
|
* ]]{
|
|
* New values for `MutationState.suidToServerId`; set/updated by by
|
|
* non-local operations once the operation has been performed. A null
|
|
* `srvid` is used to convey the header no longer exists at the previous
|
|
* name.
|
|
* }
|
|
* @key[moveMap @dictof[
|
|
* @key[oldSuid SUID]
|
|
* @value[newSuid SUID]
|
|
* ]]{
|
|
* Expresses the relationship between moved messages by local-operations.
|
|
* }
|
|
* ]]{
|
|
* A set of attributes that can be set on an operation to cause changes to
|
|
* the `MutationState` for the account. This forms part of the interface
|
|
* of the operations. The operations don't manipulate the table directly
|
|
* to reduce code duplication, ease debugging, and simplify unit testing.
|
|
* }
|
|
**/
|
|
|
|
function ImapJobDriver(account, state) {
|
|
this.account = account;
|
|
this.resilientServerIds = false;
|
|
this._heldMutexReleasers = [];
|
|
|
|
logic.defineScope(this, 'ImapJobDriver', { accountId: this.account.id });
|
|
|
|
this._state = state;
|
|
// (we only need to use one as a proxy for initialization)
|
|
if (!state.hasOwnProperty('suidToServerId')) {
|
|
state.suidToServerId = {};
|
|
state.moveMap = {};
|
|
}
|
|
|
|
this._stateDelta = {
|
|
serverIdMap: null,
|
|
moveMap: null,
|
|
};
|
|
}
|
|
exports.ImapJobDriver = ImapJobDriver;
|
|
ImapJobDriver.prototype = {
|
|
/**
|
|
* Request access to an IMAP folder to perform a mutation on it. This
|
|
* acquires a write mutex on the FolderStorage and compels the ImapFolderConn
|
|
* in question to acquire an IMAP connection if it does not already have one.
|
|
*
|
|
* The callback will be invoked with the folder and raw connections once
|
|
* they are available. The raw connection will be actively in the folder.
|
|
*
|
|
* There is no need to explicitly release the connection when done; it will
|
|
* be automatically released when the mutex is released if desirable.
|
|
*
|
|
* This will ideally be migrated to whatever mechanism we end up using for
|
|
* mailjobs.
|
|
*
|
|
* @args[
|
|
* @param[folderId]
|
|
* @param[needConn Boolean]{
|
|
* True if we should try and get a connection from the server. Local ops
|
|
* should pass false.
|
|
* }
|
|
* @param[callback @func[
|
|
* @args[
|
|
* @param[folderConn ImapFolderConn]
|
|
* @param[folderStorage FolderStorage]
|
|
* ]
|
|
* ]]
|
|
* @param[deathback Function]
|
|
* @param[label String]{
|
|
* The label to identify this usage for debugging purposes.
|
|
* }
|
|
* ]
|
|
*/
|
|
_accessFolderForMutation: function(folderId, needConn, callback, deathback,
|
|
label) {
|
|
var storage = this.account.getFolderStorageForFolderId(folderId),
|
|
self = this;
|
|
storage.runMutexed(label, function(releaseMutex) {
|
|
var syncer = storage.folderSyncer;
|
|
var action = function () {
|
|
self._heldMutexReleasers.push(releaseMutex);
|
|
try {
|
|
callback(syncer.folderConn, storage);
|
|
}
|
|
catch (ex) {
|
|
logic(self, 'callbackErr', { ex: ex });
|
|
}
|
|
};
|
|
|
|
// localdrafts and outbox are synthetic folders and so we never
|
|
// want a connection for them. This is a somewhat awkward place
|
|
// to make this decision, but it does work.
|
|
if (needConn && !storage.isLocalOnly) {
|
|
syncer.folderConn.withConnection(function () {
|
|
// When we release the mutex, the folder may not
|
|
// release its connection, so be sure to reset
|
|
// error handling (deathback). We are slightly
|
|
// abusing the mutex releasing mutex mechanism
|
|
// here. And we do want to do this before calling
|
|
// the actual mutex releaser since we might
|
|
// otherwise interact with someone else who just
|
|
// acquired the mutex, (only) theoretically.
|
|
self._heldMutexReleasers.push(function() {
|
|
syncer.folderConn.clearErrorHandler();
|
|
});
|
|
|
|
action();
|
|
},
|
|
// Always pass true for dieOnConnectFailure; we don't want any of our
|
|
// operations hanging out waiting for retry backoffs. The ops want to
|
|
// only run when we believe we are online with a good connection.
|
|
deathback, label, true);
|
|
} else {
|
|
action();
|
|
}
|
|
});
|
|
},
|
|
|
|
_partitionAndAccessFoldersSequentially:
|
|
$jobmixins._partitionAndAccessFoldersSequentially,
|
|
|
|
/**
|
|
* Request access to a connection for some type of IMAP manipulation that does
|
|
* not involve a folder known to the system (which should then be accessed via
|
|
* _accessfolderForMutation).
|
|
*
|
|
* The connection will be automatically released when the operation completes,
|
|
* there is no need to release it directly.
|
|
*/
|
|
_acquireConnWithoutFolder: function(label, callback, deathback) {
|
|
logic(this, 'acquireConnWithoutFolder_begin', { label: label });
|
|
var self = this;
|
|
this.account.__folderDemandsConnection(
|
|
null, label,
|
|
function(conn) {
|
|
logic(self, 'acquireConnWithoutFolder_end', { label: label });
|
|
self._heldMutexReleasers.push(function() {
|
|
self.account.__folderDoneWithConnection(conn, false, false);
|
|
});
|
|
try {
|
|
callback(conn);
|
|
}
|
|
catch (ex) {
|
|
logic(self, 'callbackErr', { ex: ex });
|
|
}
|
|
},
|
|
deathback
|
|
);
|
|
},
|
|
|
|
postJobCleanup: $jobmixins.postJobCleanup,
|
|
|
|
allJobsDone: $jobmixins.allJobsDone,
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// downloadBodies: Download the bodies from a list of messages
|
|
|
|
local_do_downloadBodies: $jobmixins.local_do_downloadBodies,
|
|
|
|
do_downloadBodies: $jobmixins.do_downloadBodies,
|
|
|
|
check_downloadBodies: $jobmixins.check_downloadBodies,
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// downloadBodyReps: Download the bodies from a single message
|
|
|
|
local_do_downloadBodyReps: $jobmixins.local_do_downloadBodyReps,
|
|
|
|
do_downloadBodyReps: $jobmixins.do_downloadBodyReps,
|
|
|
|
check_downloadBodyReps: $jobmixins.check_downloadBodyReps,
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// download: Download one or more attachments from a single message
|
|
|
|
local_do_download: $jobmixins.local_do_download,
|
|
|
|
do_download: $jobmixins.do_download,
|
|
|
|
check_download: $jobmixins.check_download,
|
|
|
|
local_undo_download: $jobmixins.local_undo_download,
|
|
|
|
undo_download: $jobmixins.undo_download,
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// upgrade: perfom necessary upgrades when the db version changes
|
|
|
|
local_do_upgradeDB: $jobmixins.local_do_upgradeDB,
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// modtags: Modify tags on messages
|
|
|
|
local_do_modtags: $jobmixins.local_do_modtags,
|
|
|
|
do_modtags: function(op, jobDoneCallback, undo) {
|
|
var addTags = undo ? op.removeTags : op.addTags,
|
|
removeTags = undo ? op.addTags : op.removeTags;
|
|
|
|
var aggrErr = null;
|
|
|
|
this._partitionAndAccessFoldersSequentially(
|
|
op.messages, true,
|
|
function perFolder(folderConn, storage, serverIds, namers, callWhenDone) {
|
|
var uids = [];
|
|
for (var i = 0; i < serverIds.length; i++) {
|
|
var srvid = serverIds[i];
|
|
// The header may have disappeared from the server, in which case the
|
|
// header is moot.
|
|
if (srvid)
|
|
uids.push(srvid);
|
|
}
|
|
// Be done if all of the headers were moot.
|
|
if (!uids.length) {
|
|
callWhenDone();
|
|
return;
|
|
}
|
|
|
|
var latch = allback.latch();
|
|
|
|
if (addTags) {
|
|
folderConn._conn.setFlags(
|
|
uids.join(','),
|
|
{ add: addTags },
|
|
{ byUid: true },
|
|
latch.defer('add'));
|
|
} else if (removeTags) {
|
|
folderConn._conn.setFlags(
|
|
uids.join(','),
|
|
{ remove: removeTags },
|
|
{ byUid: true },
|
|
latch.defer('remove'));
|
|
}
|
|
|
|
latch.then(function(results) {
|
|
var err = (results.add && results.add[0]) ||
|
|
(results.remove && results.remove[0]);
|
|
if (err) {
|
|
console.error('failure modifying tags', err);
|
|
aggrErr = 'unknown';
|
|
} else {
|
|
op.progress += (undo ? -serverIds.length : serverIds.length);
|
|
}
|
|
callWhenDone();
|
|
});
|
|
},
|
|
function allDone() {
|
|
jobDoneCallback(aggrErr);
|
|
},
|
|
function deadConn() {
|
|
aggrErr = 'aborted-retry';
|
|
},
|
|
/* reverse if we're undoing */ undo,
|
|
'modtags');
|
|
},
|
|
|
|
check_modtags: function(op, callback) {
|
|
callback(null, UNCHECKED_IDEMPOTENT);
|
|
},
|
|
|
|
local_undo_modtags: $jobmixins.local_undo_modtags,
|
|
|
|
undo_modtags: function(op, callback) {
|
|
// Undoing is just a question of flipping the add and remove lists.
|
|
return this.do_modtags(op, callback, true);
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// delete: Delete messages
|
|
|
|
local_do_delete: $jobmixins.local_do_delete,
|
|
|
|
/**
|
|
* Move the message to the trash folder. In Gmail, there is no move target,
|
|
* we just delete it and gmail will (by default) expunge it immediately.
|
|
*/
|
|
do_delete: function(op, doneCallback) {
|
|
var trashFolder = this.account.getFirstFolderWithType('trash');
|
|
this.do_move(op, doneCallback, trashFolder.id);
|
|
},
|
|
|
|
check_delete: function(op, doneCallback) {
|
|
var trashFolder = this.account.getFirstFolderWithType('trash');
|
|
this.check_move(op, doneCallback, trashFolder.id);
|
|
},
|
|
|
|
local_undo_delete: $jobmixins.local_undo_delete,
|
|
|
|
undo_delete: function(op, doneCallback) {
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// move: Move messages between folders (in a single account)
|
|
//
|
|
// ## General Strategy ##
|
|
//
|
|
// Local Do:
|
|
//
|
|
// - Move the header to the target folder's storage, updating the op with the
|
|
// message-id header of the message for each message so that the check
|
|
// operation has them available.
|
|
//
|
|
// This requires acquiring a write mutex to the target folder while also
|
|
// holding one on the source folder. We are assured there is no deadlock
|
|
// because only operations are allowed to manipulate multiple folders at
|
|
// once, and only one operation is in-flight per an account at a time.
|
|
// (And cross-account moves are not handled by this operation.)
|
|
//
|
|
// Insertion is done using the INTERNALDATE (which must be maintained by the
|
|
// COPY operation) and a freshly allocated id, just like if we had heard
|
|
// about the header from the server.
|
|
//
|
|
// Do:
|
|
//
|
|
// - Acquire a connection to the target folder so that we can know the UIDNEXT
|
|
// value prior to performing the copy. FUTURE: Don't do this if the server
|
|
// supports UIDPLUS.
|
|
//
|
|
// (Do the following in a loop per-source folder)
|
|
//
|
|
// - Copy the messages to the target folder via COPY.
|
|
//
|
|
// - Figure out the UIDs of our moved messages. FUTURE: If the server is
|
|
// UIDPLUS, we already know these from the results of the previous command.
|
|
// NOW: Issue a fetch on the message-id headers of the messages in the
|
|
// range UIDNEXT:*. Use these results to map the UIDs to the messages we
|
|
// copied above. In the event of duplicate message-id's, ordering doesn't
|
|
// matter, we just pick the first one. Update our UIDNEXT value in case
|
|
// there is another pass through the loop.
|
|
//
|
|
// - Issue deletes on the messages from the source folder.
|
|
//
|
|
// Check: XXX TODO POSTPONED FOR PRELIMINARY LANDING
|
|
//
|
|
// NB: Our check implementation actually is a correcting check implemenation;
|
|
// we will make things end up the way they should be. We do this because it
|
|
// is simpler than
|
|
//
|
|
// - Acquire a connection to the target folder. Issue broad message-id
|
|
// header searches to find if the messages appear to be in the folder
|
|
// already, note which are already present. This needs to take the form
|
|
// of a SEARCH followed by a FETCH to map UIDs to message-id's. In theory
|
|
// the IMAP copy command should be atomic, but I'm not sure we can trust
|
|
// that and we also have the problem where there could already be duplicate
|
|
// message-id headers in the target which could confuse us if our check is
|
|
// insufficiently thorough. The FETCH needs to also retrieve the flags
|
|
// for the message so we can track deletion state.
|
|
//
|
|
// (Do the following in a loop per source folder)
|
|
//
|
|
// - Acquire connections for each source folder. Issue message-id searches
|
|
// like we did for the target including header results. In theory we might
|
|
// remember the UIDs for check acceleration purposes, but that would not
|
|
// cover if we tried to perform an undo, so we go for thorough.
|
|
//
|
|
// -
|
|
//
|
|
// ## Possible Problems and their Solutions ##
|
|
//
|
|
// Moves are fairly complicated in terms of moving parts, so let's enumate the
|
|
// way things could go wrong so we can make sure we address them and describe
|
|
// how we address them. Note that it's a given that we will have run our
|
|
// local modifications prior to trying to talk to the server, which reduces
|
|
// the potential badness.
|
|
//
|
|
// #1: We attempt to resynchronize the source folder for a move prior to
|
|
// running the operation against the server, resulting in us synchronizing
|
|
// a duplicate header into existence that will not be detected until the
|
|
// next resync of the time range (which will be strictly after when we
|
|
// actually run the mutation.
|
|
//
|
|
// #2: Operations scheduled against speculative headers. It is quite possible
|
|
// for the user to perform actions against one of the locally /
|
|
// speculatively moved headers while we are offline/have not yet played
|
|
// the operation/are racing the UI while playing the operation. We
|
|
// obviously want these changes to succeed.
|
|
//
|
|
// Our solutions:
|
|
//
|
|
// #1: Prior to resynchronizing a folder, we check if there are any operations
|
|
// that block synchronization. An un-run move with a source of that
|
|
// folder counts as such an operation. We can determine this by either
|
|
// having sufficient knowledge to inspect an operation or have operations
|
|
// directly modify book-keeping structures in the folders as part of their
|
|
// actions. (Add blocker on local_(un)do, remove on (un)do.) We choose
|
|
// to implement the inspection operation by having all operations
|
|
// implement a simple helper to tell us if the operation blocks entry.
|
|
// The theory is this will be less prone to bugs since it will be clear
|
|
// that operations need to implement the method, whereas it would be less
|
|
// clear that operations need to do call the folder-state mutating
|
|
// options.
|
|
//
|
|
// #2: Operations against speculative headers are a concern only from a naming
|
|
// perspective for operations. Operations are strictly run in the order
|
|
// they are enqueued, so we know that the header will have been moved and
|
|
// be in the right folder. Additionally, because both the UI and
|
|
// operations name messages using an id we issue rather than the server
|
|
// UID, there is no potential for naming inconsistencies. The UID will be
|
|
// resolved at operation run-time which only requires that the move
|
|
// operation either was UIDPLUS or we manually sussed out the target id
|
|
// (which we do for simplicity).
|
|
//
|
|
// XXX problem: repeated moves and UIDs.
|
|
// what we do know:
|
|
// - in order to know about a message, we must have a current UID of the
|
|
// message on the server where it currently lives.
|
|
// what we could do:
|
|
// - have successor move operations moot/replace their predecessor. So a
|
|
// move from A to B, and then from B to C will just become a move from A to
|
|
// C from the perspective of the online op that will eventually be run. We
|
|
// could potentially build this on top of a renaming strategy. So if we
|
|
// move z-in-A to z-in-B, and then change a tag on z-in-B, and then move
|
|
// z-in-B to z-in-C, renaming and consolidatin would make this a move of
|
|
// z-in-A to z-in-C followed by a tag change on z-in-C.
|
|
// - produce micro-IMAP-ops as a byproduct of our local actions that are
|
|
// stored on the operation. So in the A/move to B/tag/move to C case above,
|
|
// we would not consolidate anything, just produce a transaction journal.
|
|
// The A-move-to-B case would be covered by serializing the information
|
|
// for the IMAP COPY and deletion. In the UIDPLUS case, we have an
|
|
// automatic knowledge of the resulting new target UID; in the non-UIDPLUS
|
|
// case we can open the target folder and find out the new UID as part of
|
|
// the micro-op. The question here is then how we chain these various
|
|
// results together in the multi-move case, or when we write the result to
|
|
// the target:
|
|
// - maintain an output value map for the operations. When there is just
|
|
// the one move, the output for the UID for each move is the current
|
|
// header name of the message, which we will load and write the value
|
|
// into. When there are multiple moves, the output map is adjusted and
|
|
// used to indicate that we should stash the UID in quasi-persistent
|
|
// storage for a subsequent move operation. (This could be thought of
|
|
// as similar to the renaming logic, but explicit.)
|
|
|
|
|
|
local_do_move: $jobmixins.local_do_move,
|
|
|
|
do_move: function(op, jobDoneCallback, targetFolderId) {
|
|
var state = this._state, stateDelta = this._stateDelta, aggrErr = null;
|
|
if (!stateDelta.serverIdMap)
|
|
stateDelta.serverIdMap = {};
|
|
if (!targetFolderId)
|
|
targetFolderId = op.targetFolder;
|
|
|
|
this._partitionAndAccessFoldersSequentially(
|
|
op.messages, true,
|
|
function perFolder(folderConn, sourceStorage, serverIds, namers,
|
|
perFolderDone){
|
|
// XXX process UIDPLUS output when present, avoiding this step.
|
|
var guidToNamer = {}, targetConn;
|
|
|
|
// - got the target folder conn, now do the copies
|
|
function gotTargetConn(targetConn, targetStorage) {
|
|
var usingUid, nextId;
|
|
// Some servers don't provide
|
|
if (targetConn.box.uidNext) {
|
|
usingUid = true;
|
|
nextId = targetConn.box.uidNext;
|
|
} else {
|
|
usingUid = false;
|
|
// Message sequence numbers are 1-based, so if 0 exist, then 1 is
|
|
// the sequence number of the next message. If 1 exists, its
|
|
// number is 1, and the next number is 2. And so on.
|
|
nextId = targetConn.box.exists + 1;
|
|
}
|
|
|
|
folderConn._conn.copyMessages(serverIds.join(','),
|
|
targetStorage.folderMeta.path,
|
|
{ byUid: true },
|
|
copiedMessages_reselect);
|
|
|
|
function copiedMessages_reselect() {
|
|
// Force a re-select of the folder to try and force the server to
|
|
// perceive the move. This was necessary for me at least on my
|
|
// dovceot test setup. Although we had heard that the COPY
|
|
// completed, our FETCH was too fast, although an IDLE did report
|
|
// the new messages after that.
|
|
targetConn._conn.selectMailbox(targetStorage.folderMeta.path,
|
|
copiedMessages_findNewUIDs);
|
|
}
|
|
// - copies are done, find the UIDs
|
|
function copiedMessages_findNewUIDs() {
|
|
var fetcher = targetConn._conn.listMessages(
|
|
nextId + ':*',
|
|
['UID', 'BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)]'],
|
|
{ byUid: usingUid },
|
|
function (err, messages) {
|
|
if (err) {
|
|
perFolderDone();
|
|
} else {
|
|
var latch = allback.latch();
|
|
messages.forEach(function(msg) {
|
|
var messageDone = latch.defer();
|
|
|
|
for (var key in msg) {
|
|
if (/header\.fields/.test(key)) {
|
|
var headerParser = new MimeParser();
|
|
headerParser.write(msg[key] + '\r\n');
|
|
headerParser.end();
|
|
msg.headers = headerParser.node.headers;
|
|
break;
|
|
}
|
|
}
|
|
|
|
var guid = msg.headers['message-id'] &&
|
|
msg.headers['message-id'][0] &&
|
|
msg.headers['message-id'][0].value;
|
|
if (guid && guid[0] === '<') {
|
|
guid = guid.slice(1, -1); // Strip surrounding brackets.
|
|
}
|
|
|
|
if (!guidToNamer.hasOwnProperty(guid)) {
|
|
messageDone();
|
|
return;
|
|
}
|
|
var namer = guidToNamer[guid];
|
|
stateDelta.serverIdMap[namer.suid] = msg.uid;
|
|
var newSuid = state.moveMap[namer.suid];
|
|
var newId =
|
|
parseInt(newSuid.substring(newSuid.lastIndexOf('/') + 1));
|
|
|
|
targetStorage.updateMessageHeader(
|
|
namer.date, newId, false,
|
|
function(header) {
|
|
// If the header isn't there because it got
|
|
// moved, then null will be returned and it's
|
|
// up to the next move operation to fix this up.
|
|
if (header)
|
|
header.srvid = msg.uid;
|
|
else
|
|
console.warn('did not find header for', namer.suid,
|
|
newSuid, namer.date, newId);
|
|
|
|
messageDone();
|
|
return true;
|
|
}, /* body hint */ null);
|
|
});
|
|
latch.then(foundUIDs_deleteOriginals);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function foundUIDs_deleteOriginals() {
|
|
folderConn._conn.deleteMessages(
|
|
serverIds.join(','),
|
|
{ byUid: true },
|
|
deletedMessages);
|
|
}
|
|
function deletedMessages(err) {
|
|
if (err)
|
|
aggrErr = true;
|
|
perFolderDone();
|
|
}
|
|
|
|
// Build a guid-to-namer map and deal with any messages that no longer
|
|
// exist on the server. Do it backwards so we can splice.
|
|
for (var i = namers.length - 1; i >= 0; i--) {
|
|
var srvid = serverIds[i];
|
|
if (!srvid) {
|
|
serverIds.splice(i, 1);
|
|
namers.splice(i, 1);
|
|
continue;
|
|
}
|
|
var namer = namers[i];
|
|
guidToNamer[namer.guid] = namer;
|
|
}
|
|
// it's possible all the messages could be gone, in which case we
|
|
// are done with this folder already!
|
|
if (serverIds.length === 0) {
|
|
perFolderDone();
|
|
return;
|
|
}
|
|
|
|
// There is nothing to do on localdrafts or outbox folders, server-wise.
|
|
|
|
if (sourceStorage.isLocalOnly) {
|
|
perFolderDone();
|
|
}
|
|
else if (sourceStorage.folderId === targetFolderId) {
|
|
if (op.type === 'move') {
|
|
// A move from a folder to itself is a no-op.
|
|
perFolderDone();
|
|
}
|
|
else { // op.type === 'delete'
|
|
// If the op is a delete and the source and destination folders
|
|
// match, we're deleting from trash, so just perma-delete it.
|
|
foundUIDs_deleteOriginals();
|
|
}
|
|
}
|
|
else {
|
|
// Resolve the target folder again.
|
|
this._accessFolderForMutation(targetFolderId, true, gotTargetConn,
|
|
function targetFolderDead() {},
|
|
'move target');
|
|
}
|
|
}.bind(this),
|
|
function() {
|
|
jobDoneCallback(aggrErr);
|
|
},
|
|
null,
|
|
false,
|
|
'server move source');
|
|
},
|
|
|
|
/**
|
|
* See section block comment for more info.
|
|
*
|
|
* XXX implement checking logic for move
|
|
*/
|
|
check_move: function(op, doneCallback, targetFolderId) {
|
|
// get a connection in the target folder
|
|
// do a search on message-id's to check if the messages got copied across.
|
|
doneCallback(null, 'moot');
|
|
},
|
|
|
|
local_undo_move: $jobmixins.local_undo_move,
|
|
|
|
/**
|
|
* Move the message back to its original folder.
|
|
*
|
|
* - If the source message has not been expunged, remove the Deleted flag from
|
|
* the source folder.
|
|
* - If the source message was expunged, copy the message back to the source
|
|
* folder.
|
|
* - Delete the message from the target folder.
|
|
*
|
|
* XXX implement undo functionality for move
|
|
*/
|
|
undo_move: function(op, doneCallback, targetFolderId) {
|
|
doneCallback('moot');
|
|
},
|
|
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// append: Add a message to a folder
|
|
//
|
|
// Message should look like:
|
|
// {
|
|
// messageText: the message body,
|
|
// date: the date to use as the INTERNALDATE of the message,
|
|
// flags: the initial set of flags for the message
|
|
// }
|
|
|
|
local_do_append: function(op, doneCallback) {
|
|
doneCallback(null);
|
|
},
|
|
|
|
/**
|
|
* Append a message to a folder.
|
|
*/
|
|
do_append: function(op, callback) {
|
|
var folderConn, self = this,
|
|
storage = this.account.getFolderStorageForFolderId(op.folderId),
|
|
folderMeta = storage.folderMeta,
|
|
iNextMessage = 0;
|
|
|
|
var gotFolderConn = function gotFolderConn(_folderConn) {
|
|
if (!_folderConn) {
|
|
done('unknown');
|
|
return;
|
|
}
|
|
folderConn = _folderConn;
|
|
append();
|
|
};
|
|
var deadConn = function deadConn() {
|
|
callback('aborted-retry');
|
|
};
|
|
var append = function append() {
|
|
var message = op.messages[iNextMessage++];
|
|
|
|
// XXX: browserbox does not support sending blobs...
|
|
var str = new FileReaderSync().readAsBinaryString(message.messageText);
|
|
folderConn._conn.upload(
|
|
folderMeta.path,
|
|
str,
|
|
{ flags: message.flags },
|
|
appended);
|
|
};
|
|
var appended = function appended(err) {
|
|
if (err) {
|
|
console.error('failure appending message', err);
|
|
done('unknown');
|
|
return;
|
|
}
|
|
if (iNextMessage < op.messages.length)
|
|
append();
|
|
else
|
|
done(null);
|
|
};
|
|
var done = function done(errString) {
|
|
if (folderConn)
|
|
folderConn = null;
|
|
callback(errString);
|
|
};
|
|
|
|
this._accessFolderForMutation(op.folderId, true, gotFolderConn, deadConn,
|
|
'append');
|
|
},
|
|
|
|
/**
|
|
* Check if the message ended up in the folder.
|
|
*
|
|
* TODO implement
|
|
*/
|
|
check_append: function(op, doneCallback) {
|
|
// XXX search on the message-id in the folder to verify its presence.
|
|
doneCallback(null, 'moot');
|
|
},
|
|
|
|
// TODO implement
|
|
local_undo_append: function(op, doneCallback) {
|
|
doneCallback(null);
|
|
},
|
|
|
|
// TODO implement
|
|
undo_append: function(op, doneCallback) {
|
|
doneCallback('moot');
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// syncFolderList
|
|
//
|
|
// Synchronize our folder list. This should always be an idempotent operation
|
|
// that makes no sense to undo/redo/etc.
|
|
|
|
local_do_syncFolderList: function(op, doneCallback) {
|
|
doneCallback(null);
|
|
},
|
|
|
|
do_syncFolderList: function(op, doneCallback) {
|
|
var account = this.account, reported = false;
|
|
this._acquireConnWithoutFolder(
|
|
'syncFolderList',
|
|
function gotConn(conn) {
|
|
account._syncFolderList(conn, function(err) {
|
|
if (!err)
|
|
account.meta.lastFolderSyncAt = Date.now();
|
|
// request an account save
|
|
if (!reported)
|
|
doneCallback(err ? 'aborted-retry' : null, null, !err);
|
|
reported = true;
|
|
});
|
|
},
|
|
function deadConn() {
|
|
if (!reported)
|
|
doneCallback('aborted-retry');
|
|
reported = true;
|
|
});
|
|
},
|
|
|
|
check_syncFolderList: function(op, doneCallback) {
|
|
doneCallback(null, 'coherent-notyet');
|
|
},
|
|
|
|
local_undo_syncFolderList: function(op, doneCallback) {
|
|
doneCallback('moot');
|
|
},
|
|
|
|
undo_syncFolderList: function(op, doneCallback) {
|
|
doneCallback('moot');
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// createFolder: Create a folder
|
|
|
|
local_do_createFolder: function(op, doneCallback) {
|
|
// we never locally perform this operation.
|
|
doneCallback(null);
|
|
},
|
|
|
|
do_createFolder: function(op, doneCallback) {
|
|
var parentFolderInfo;
|
|
if (op.parentFolderId) {
|
|
if (!this.account._folderInfos.hasOwnProperty(op.parentFolderId)) {
|
|
throw new Error("No such folder: " + op.parentFolderId);
|
|
}
|
|
parentFolderInfo = this.account._folderInfos[op.parentFolderId].$meta;
|
|
}
|
|
|
|
var personalNamespace =
|
|
(this.account._namespaces && this.account._namespaces.personal) ||
|
|
{ prefix: '', delimiter: FALLBACK_FOLDER_DELIM };
|
|
|
|
var derivedInfo = deriveFolderPath(
|
|
op.folderName, op.containOtherFolders, parentFolderInfo,
|
|
personalNamespace);
|
|
var path = derivedInfo.path;
|
|
|
|
var scope = logic.subscope(this, { _path: path });
|
|
|
|
var gotConn = function(conn) {
|
|
// - create the box
|
|
// Paths are private.
|
|
logic(scope, 'creatingFolder', { _path: path });
|
|
conn.createMailbox(path, addBoxCallback);
|
|
}.bind(this);
|
|
|
|
var addBoxCallback = function(err, alreadyExists) {
|
|
if (err) {
|
|
// upgrade error message that contain "already" to mean ALREADYEXISTS.
|
|
// TODO: make hoodiecrow support ALREADYEXISTS
|
|
if (err.message && /already/i.test(err.message)) {
|
|
alreadyExists = true;
|
|
} else {
|
|
logic(scope, 'createFolderErr', { err: err });
|
|
// TODO: do something clever in terms of making sure the folder didn't
|
|
// already exist and the server just doesn't like to provide the
|
|
// ALREADYEXISTS response.
|
|
//
|
|
// For now, give up immediately on the folder for safety.
|
|
// ensureEssentialFolders is the only logic that creates folders and it
|
|
// does not know about existing/pending createFolder requests, so it's
|
|
// for the best if we simply give up permanently on this.
|
|
done('failure-give-up', null);
|
|
return;
|
|
}
|
|
}
|
|
|
|
logic(scope, 'createdFolder', { alreadyExists: alreadyExists });
|
|
|
|
// We originally (under imap.js) would do a LIST against the folder for
|
|
// the path we thought we just created and then we would use that to
|
|
// learn about the folder like we had heard about it from syncFolderList
|
|
// (but more efficiently). This really only was needed because we
|
|
// potentially would screw up when it came to IMAP namespaces. We now
|
|
// try to correctly honor the namespace so we just assume if the folder
|
|
// says it created the folder that it didn't do any crazy transforms
|
|
// that it won't also do when we otherwise deal with the folder.
|
|
//
|
|
// An alternative would be to not learn about the folder now and instead
|
|
// just trigger syncFolderList. This gets tricky in the face of us
|
|
// potentially trying to create multiple folders and infinite loops if we
|
|
// screw up, so we don't do that.
|
|
var folderMeta = this.account._learnAboutFolder(
|
|
op.folderName, path, op.parentFolderId, op.folderType,
|
|
derivedInfo.delimiter, derivedInfo.depth,
|
|
// do not suppress the add notification
|
|
false);
|
|
done(null, folderMeta);
|
|
}.bind(this);
|
|
function done(errString, folderMeta) {
|
|
if (doneCallback) {
|
|
doneCallback(errString, folderMeta);
|
|
doneCallback = null;
|
|
}
|
|
}
|
|
function deadConn() {
|
|
done('aborted-retry', null);
|
|
}
|
|
|
|
// Check to make sure the folder doesn't already exist and early return if
|
|
// it does exist. There is nothing preventing a caller from making
|
|
// ridiculous calls like this or emergent ridiculosity from syncFolderList
|
|
// being triggered multiple times and interacting with complicated failures.
|
|
//
|
|
// Note that there is a hypothetical edge case here related to case
|
|
// normalization (ex where Gmail can report the Inbox as "Inbox" but then
|
|
// allows subfolders to be reported like INBOX/Blah) that is already
|
|
// mitigated by some smarts in browserbox when it builds the folder
|
|
// hierarchies.
|
|
var existingFolder = this.account.getFolderByPath(path);
|
|
if (existingFolder) {
|
|
done(null, existingFolder);
|
|
} else {
|
|
this._acquireConnWithoutFolder('createFolder', gotConn, deadConn);
|
|
}
|
|
},
|
|
|
|
check_createFolder: function(op, doneCallback) {
|
|
doneCallback('moot');
|
|
},
|
|
|
|
local_undo_createFolder: function(op, doneCallback) {
|
|
doneCallback(null);
|
|
},
|
|
|
|
// TODO: port deleteFolder to be an op and invoke it here
|
|
undo_createFolder: function(op, doneCallback) {
|
|
doneCallback('moot');
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
// purgeExcessMessages
|
|
|
|
local_do_purgeExcessMessages: function(op, doneCallback) {
|
|
this._accessFolderForMutation(
|
|
op.folderId, false,
|
|
function withMutex(_ignoredConn, storage) {
|
|
storage.purgeExcessMessages(function(numDeleted, cutTS) {
|
|
// Indicate that we want a save performed if any messages got deleted.
|
|
doneCallback(null, null, numDeleted > 0);
|
|
});
|
|
},
|
|
null,
|
|
'purgeExcessMessages');
|
|
},
|
|
|
|
do_purgeExcessMessages: function(op, doneCallback) {
|
|
doneCallback(null);
|
|
},
|
|
|
|
check_purgeExcessMessages: function(op, doneCallback) {
|
|
// this is a local-only modification, so this doesn't really matter
|
|
return UNCHECKED_IDEMPOTENT;
|
|
},
|
|
|
|
local_undo_purgeExcessMessages: function(op, doneCallback) {
|
|
doneCallback(null);
|
|
},
|
|
|
|
undo_purgeExcessMessages: function(op, doneCallback) {
|
|
doneCallback(null);
|
|
},
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
|
|
local_do_sendOutboxMessages: $jobmixins.local_do_sendOutboxMessages,
|
|
do_sendOutboxMessages: $jobmixins.do_sendOutboxMessages,
|
|
check_sendOutboxMessages: $jobmixins.check_sendOutboxMessages,
|
|
local_undo_sendOutboxMessages: $jobmixins.local_undo_sendOutboxMessages,
|
|
undo_sendOutboxMessages: $jobmixins.undo_sendOutboxMessages,
|
|
local_do_setOutboxSyncEnabled: $jobmixins.local_do_setOutboxSyncEnabled
|
|
|
|
//////////////////////////////////////////////////////////////////////////////
|
|
|
|
};
|
|
|
|
function HighLevelJobDriver() {
|
|
}
|
|
HighLevelJobDriver.prototype = {
|
|
/**
|
|
* Perform a cross-folder move:
|
|
*
|
|
* - Fetch the entirety of a message from the source location.
|
|
* - Append the entirety of the message to the target location.
|
|
* - Delete the message from the source location.
|
|
*/
|
|
do_xmove: function() {
|
|
},
|
|
|
|
check_xmove: function() {
|
|
|
|
},
|
|
|
|
/**
|
|
* Undo a cross-folder move. Same idea as for normal undo_move; undelete
|
|
* if possible, re-copy if not. Delete the target once we're confident
|
|
* the message made it back into the folder.
|
|
*/
|
|
undo_xmove: function() {
|
|
},
|
|
|
|
/**
|
|
* Perform a cross-folder copy:
|
|
* - Fetch the entirety of a message from the source location.
|
|
* - Append the message to the target location.
|
|
*/
|
|
do_xcopy: function() {
|
|
},
|
|
|
|
check_xcopy: function() {
|
|
},
|
|
|
|
/**
|
|
* Just delete the message from the target location.
|
|
*/
|
|
undo_xcopy: function() {
|
|
},
|
|
};
|
|
|
|
mix(ImapJobDriver.prototype, draftsJobs.draftsMixins);
|
|
|
|
|
|
}); // end define
|