VulpesOS/gaia/apps/email/js/ext/imap/jobs.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

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