/** * **/ /*global define, console, window, Blob */ define( [ 'logic', './a64', './date', './syncbase', './worker-router', './maildb', './cronsync', './accountcommon', './allback', 'module', 'exports' ], function( logic, $a64, $date, $syncbase, $router, $maildb, $cronsync, $acctcommon, $allback, $module, exports ) { /** * How many operations per account should we track to allow for undo operations? * The B2G email app only demands a history of 1 high-level op for undoing, but * we are supporting somewhat more for unit tests, potential fancier UIs, and * because high-level ops may end up decomposing into multiple lower-level ops * someday. * * This limit obviously is not used to discard operations not yet performed! */ var MAX_MUTATIONS_FOR_UNDO = 10; /** * When debug logging is enabled, how long should we store logs in the * circular buffer? */ var MAX_LOG_BACKLOG_MS = 30000; /** * Creates a method to add to MailUniverse that calls a method * on all bridges. * @param {String} bridgeMethod name of bridge method to call * @return {Function} function to attach to MailUniverse. Assumes * "this" is the MailUniverse instance, and that up to three args * are passed to the method. */ function makeBridgeFn(bridgeMethod) { return function(a1, a2, a3) { for (var iBridge = 0; iBridge < this._bridges.length; iBridge++) { var bridge = this._bridges[iBridge]; bridge[bridgeMethod](a1, a2, a3); } }; } /** * The MailUniverse is the keeper of the database, the root logging instance, * and the mail accounts. It loads the accounts from the database on startup * asynchronously, so whoever creates it needs to pass a callback for it to * invoke on successful startup. * * Our concept of mail accounts bundles together both retrieval (IMAP, * activesync) and sending (SMTP, activesync) since they really aren't * separable and in some cases are basically the same (activesync) or coupled * (BURL SMTP pulling from IMAP, which we don't currently do but aspire to). * * @typedef[ConnInfo @dict[ * @key[hostname] * @key[port] * @key[crypto @oneof[ * @case[false]{ * No encryption; plaintext. * } * @case['starttls']{ * Upgrade to TLS after establishing a plaintext connection. Abort if * the server seems incapable of performing the upgrade. * } * @case[true]{ * Establish a TLS connection from the get-go; never use plaintext at all. * By convention this may be referred to as an SSL or SSL/TLS connection. * } * ]] * @typedef[AccountCredentials @dict[ * @key[username String]{ * The name we use to identify ourselves to the server. This will * frequently be the whole e-mail address. Ex: "joe@example.com" rather * than just "joe". * } * @key[password String]{ * The password. Ideally we would have a keychain mechanism so we wouldn't * need to store it like this. * } * ]] * @typedef[IdentityDef @dict[ * @key[id String]{ * Unique identifier resembling folder id's; * "{account id}-{unique value for this account}" is what it looks like. * } * @key[name String]{ * Display name, ex: "Joe User". * } * @key[address String]{ * E-mail address, ex: "joe@example.com". * } * @key[replyTo @oneof[null String]]{ * The e-mail address to put in the "reply-to" header for recipients * to address their replies to. If null, the header will be omitted. * } * @key[signature @oneof[null String]]{ * An optional signature block. If present, we ensure the body text ends * with a newline by adding one if necessary, append "-- \n", then append * the contents of the signature. Once we start supporting HTML, we will * need to indicate whether the signature is plaintext or HTML. For now * it must be plaintext. * } * ]] * @typedef[UniverseConfig @dict[ * @key[nextAccountNum Number] * @key[nextIdentityNum Number] * @key[debugLogging Boolean]{ * Has logging been turned on for debug purposes? * } * ]]{ * The configuration fields stored in the database. * } * @typedef[AccountDef @dict[ * @key[id AccountId] * @key[name String]{ * The display name for the account. * } * @key[identities @listof[IdentityDef]] * * @key[type @oneof['pop3+smtp' 'imap+smtp' 'activesync']] * @key[receiveType @oneof['pop3' 'imap' 'activesync']] * @key[sendType @oneof['smtp' 'activesync']] * @key[receiveConnInfo ConnInfo] * @key[sendConnInfo ConnInfo] * ]] * @typedef[MessageNamer @dict[ * @key[date DateMS] * @key[suid SUID] * ]]{ * The information we need to locate a message within our storage. When the * MailAPI tells the back-end things, it uses this representation. * } * @typedef[SerializedMutation @dict[ * @key[type @oneof[ * @case['modtags']{ * Modify tags by adding and/or removing them. Idempotent and atomic * under all implementations; no explicit account saving required. * } * @case['delete']{ * Delete a message under the "move to trash" model. For IMAP, this is * the same as a move operation. * } * @case['move']{ * Move message(s) within the same account. For IMAP, this is neither * atomic or idempotent and requires account state to be checkpointed as * running the operation prior to running it. Dunno for ActiveSync, but * probably atomic and idempotent. * } * @case['copy']{ * NOT YET IMPLEMENTED (no gaia UI requirement). But will be: * Copy message(s) within the same account. For IMAP, atomic and * idempotent. * } * ]]{ * The implementation opcode used to determine what functions to call. * } * @key[longtermId]{ * Unique-ish identifier for the mutation. Just needs to be unique enough * to not refer to any pending or still undoable-operation. * } * @key[lifecyle @oneof[ * @case['do']{ * The initial state of an operation; indicates we want to execute the * operation to completion. * } * @case['done']{ * The operation completed, it's done! * } * @case['undo']{ * We want to undo the operation. * } * @case['undone']{ * } * @case['moot']{ * Either the local or server operation failed and mooted the operation. * } * ]]{ * Tracks the overall desired state and completion state of the operation. * Operations currently cannot be redone after they are undone. This field * differs from the `localStatus` and `serverStatus` in that they track * what we have done to the local database and the server rather than our * goals. It is very possible for an operation to have a lifecycle of * 'undone' without ever having manipulated the local database or told the * server anything. * } * @key[localStatus @oneof[ * @case[null]{ * Nothing has happened; no changes have been made to the local database. * } * @case['doing']{ * 'local_do' is running. An attempt to undo the operation while in this * state will not interrupt 'local_do', but will enqueue the operation * to run 'local_undo' subsequently. * } * @case['done']{ * 'local_do' has successfully run to completion. * } * @case['undoing']{ * 'local_undo' is running. * } * @case['undone']{ * 'local_undo' has successfully run to completion or we canceled the * operation * } * @case['unknown']{ * We're not sure what actually got persisted to disk. If we start * generating more transactions once we're sure the I/O won't be harmful, * we can remove this state. * } * ]]{ * The state of the local mutation effects of this operation. This used * to be conflated together with `serverStatus` in a single status variable, * but the multiple potential undo transitions once local_do became async * made this infeasible. * } * @key[serverStatus @oneof[ * @case[null]{ * Nothing has happened; no attempt has been made to talk to the server. * } * @case['check']{ * We don't know what has or hasn't happened on the server so we need to * run a check operation before doing anything. * } * @case['checking']{ * A check operation is currently being run. * } * @case['doing']{ * 'do' is currently running. Invoking `undoMutation` will not attempt to * stop 'do', but will enqueue the operation with a desire of 'undo' to be * run later. * } * @case['done']{ * 'do' successfully ran to completion. * } * @case['undoing']{ * 'undo' is currently running. Invoking `undoMutation` will not attempt * to stop this but will enqueut the operation with a desire of 'do' to be * run later. * } * @case['undone']{ * The operation was 'done' and has now been 'undone'. * } * @case['moot']{ * The job is no longer relevant; the messages it operates on don't exist, * the target folder doesn't exist, or we failed so many times that we * assume something is fundamentally wrong and the request simply cannot * be executed. * } * @case['n/a']{ * The op does not need to be run online. * } * ]]{ * The state of the operation on the server. This is tracked separately * from the `localStatus` to reduce the number of possible states. * } * @key[tryCount Number]{ * How many times have we attempted to run this operation. If we retry an * operation too many times, we eventually will discard it with the * assumption that it's never going to succeed. * } * @key[humanOp String]{ * The user friendly opcode where flag manipulations like starring have * their own opcode. * } * @key[messages @listof[MessageNamer]] * * @key[folderId #:optional FolderId]{ * If this is a move/copy, the target folder * } * ]] */ function MailUniverse(callAfterBigBang, online, testOptions) { /** @listof[Account] */ this.accounts = []; this._accountsById = {}; /** @listof[IdentityDef] */ this.identities = []; this._identitiesById = {}; /** * @dictof[ * @key[AccountID] * @value[@dict[ * @key[active Boolean]{ * Is there an active operation right now? * } * @key[local @listof[SerializedMutation]]{ * Operations to be run for local changes. This queue is drained with * preference to the `server` queue. Operations on this list will also * be added to the `server` list. * } * @key[server @listof[SerializedMutation]]{ * Operations to be run against the server. * } * @key[deferred @listof[SerializedMutation]]{ * Operations that were taken out of either of the above queues because * of a failure where we need to wait some amount of time before * retrying. * } * ]] * ]{ * Per-account lists of operations to run for local changes (first priority) * and against the server (second priority). This does not contain * completed operations; those are stored on `MailAccount.mutations` (along * with uncompleted operations!) * } */ this._opsByAccount = {}; // populated by waitForAccountOps, invoked when all ops complete this._opCompletionListenersByAccount = {}; // maps longtermId to a callback that cares. non-persisted. this._opCallbacks = {}; this._bridges = []; this._testModeDisablingLocalOps = false; /** Fake navigator to use for navigator.onLine checks */ this._testModeFakeNavigator = (testOptions && testOptions.fakeNavigator) || null; // We used to try and use navigator.connection, but it's not supported on B2G, // so we have to use navigator.onLine like suckers. this.online = true; // just so we don't cause an offline->online transition // Events for online/offline are now pushed into us externally. They need // to be bridged from the main thread anyways, so no point faking the event // listener. this._onConnectionChange(online); // Track the mode of the universe. Values are: // 'cron': started up in background to do tasks like sync. // 'interactive': at some point during its life, it was used to // provide functionality to a user interface. Once it goes // 'interactive', it cannot switch back to 'cron'. this._mode = 'cron'; /** * A setTimeout handle for when we next dump deferred operations back onto * their operation queues. */ this._deferredOpTimeout = null; this._boundQueueDeferredOps = this._queueDeferredOps.bind(this); this.config = null; this._logBacklog = []; this._db = new $maildb.MailDB(testOptions); this._cronSync = null; var self = this; this._db.getConfig(function(configObj, accountInfos, lazyCarryover) { function setupLogging(config) { // To avoid being overly verbose, and to avoid revealing private // information in logs (unless we've explicitly enabled it), we censor // event details when in secretDebugMode and for console logs. function censorLogs() { logic.isCensored = true; function censorValue(value) { if (value && (value.suid || value.srvid)) { return { date: value.date, suid: value.suid, srvid: value.srvid }; } else if (value && typeof value === 'object') { return value.toString(); } else { return value; } } // We: // - Remove properties starting with an underscore. // - Process one level of Arrays. // - Allow primitives to pass through. // - Objects get stringified unless they are a mail header, // in which case we return just the date/suid/srvid. logic.on('censorEvent', function(e) { if (logic.isPlainObject(e.details)) { for (var key in e.details) { var value = e.details[key]; if (key[0] === '_') { delete e.details[key]; } else if (Array.isArray(value)) { // Include one level of arrays. e.details[key] = value.map(censorValue); } else { e.details[key] = censorValue(value); } } } }); } if (self.config.debugLogging) { if (self.config.debugLogging === 'realtime-dangerous') { console.warn('!!!'); console.warn('!!! REALTIME USER-DATA ENTRAINING LOGGING ENABLED !!!'); console.warn('!!!'); console.warn('You are about to see a lot of logs, as they happen!'); console.warn('They will also be circularly buffered for saving.'); console.warn(''); console.warn('These logs will contain SENSITIVE DATA. The CONTENTS'); console.warn('OF EMAILS, maybe some PASSWORDS. This was turned on'); console.warn('via the secret debug mode UI. Use it to turn us off:'); console.warn('https://wiki.mozilla.org/Gaia/Email/SecretDebugMode'); logic.realtimeLogEverything = true; } else if (self.config.debugLogging !== 'dangerous') { console.warn('GENERAL LOGGING ENABLED!'); console.warn('(CIRCULAR EVENT LOGGING WITH NON-SENSITIVE DATA)'); censorLogs(); } else { console.warn('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); console.warn('DANGEROUS USER-DATA ENTRAINING LOGGING ENABLED !!!'); console.warn('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!'); console.warn('This means contents of e-mails and passwords if you'); console.warn('set up a new account. (The IMAP protocol sanitizes'); console.warn('passwords, but the bridge logger may not.)'); console.warn(''); console.warn('If you forget how to turn us off, see:'); console.warn('https://wiki.mozilla.org/Gaia/Email/SecretDebugMode'); console.warn('...................................................'); } } else if (!logic.underTest) { censorLogs(); var NAMESPACES_TO_ALWAYS_LOG = [ 'BrowserBox', 'SmtpClient', 'ActivesyncConfigurator', 'ImapFolderSync', 'Pop3Prober', 'Autoconfigurator', 'DisasterRecovery', 'ImapClient', 'ImapJobDriver', 'Oauth', 'Pop3FolderSyncer', 'SmtpProber' ]; // If we don't have debug logging enabled, bail out early by // short-circuiting any events that wouldn't be logged anyway. logic.on('preprocessEvent', function(e) { var eventShouldBeLogged = ( NAMESPACES_TO_ALWAYS_LOG.indexOf(e.namespace) !== -1 || // The smtp portion uses a namespace of 'Account', but we want it. (e.namespace === 'Account' && e.details && e.details.accountType === 'smtp') || // We also want these. e.type === 'allOpsCompleted' || e.type === 'mailslice:mutex-released' ); if (!eventShouldBeLogged) { e.preventDefault(); } }); // Then, since only the logs we care about make it this far, we can log // all remaining events here. logic.on('event', function(e) { var obj = e.toJSON(); dump('[' + obj.namespace + '] ' + obj.type + ' ' + JSON.stringify(obj.details) + '\n'); }); } } var accountInfo, i; var doneCount = 0; var accountCount = accountInfos.length; if (configObj) { self.config = configObj; setupLogging(); logic.defineScope(self, 'MailUniverse'); if (self.config.debugLogging) self._enableCircularLogging(); logic(self, 'configLoaded', { config: self.config, accountInfos: accountInfos }); function done() { doneCount += 1; if (doneCount === accountCount) { self._initFromConfig(); callAfterBigBang(); } } if (accountCount) { for (i = 0; i < accountCount; i++) { accountInfo = accountInfos[i]; self._loadAccount(accountInfo.def, accountInfo.folderInfo, null, done); } // return since _loadAccount needs to finish before completing // the flow in done(). return; } } else { self.config = { // We need to put the id in here because our startup query can't // efficiently get both the key name and the value, just the values. id: 'config', nextAccountNum: 0, nextIdentityNum: 0, debugLogging: lazyCarryover ? lazyCarryover.config.debugLogging : false }; setupLogging(); logic.defineScope(self, 'MailUniverse'); if (self.config.debugLogging) self._enableCircularLogging(); self._db.saveConfig(self.config); // - Try to re-create any accounts using old account infos. if (lazyCarryover) { logic(self, 'configMigrating_begin', { lazyCarryover: lazyCarryover }); var waitingCount = lazyCarryover.accountInfos.length; var oldVersion = lazyCarryover.oldVersion; var accountRecreated = function(accountInfo, err) { logic(self, 'recreateAccount_end', { type: accountInfo.type, id: accountInfo.id, error: err }); // We don't care how they turn out, just that they get a chance // to run to completion before we call our bootstrap complete. if (--waitingCount === 0) { logic(self, 'configMigrating_end'); this._initFromConfig(); callAfterBigBang(); } }; for (i = 0; i < lazyCarryover.accountInfos.length; i++) { var accountInfo = lazyCarryover.accountInfos[i]; logic(this, 'recreateAccount_begin', { type: accountInfo.type, id: accountInfo.id, error: null }); $acctcommon.recreateAccount( self, oldVersion, accountInfo, accountRecreated.bind(this, accountInfo)); } // Do not let callAfterBigBang get called. return; } else { logic(self, 'configCreated', { config: self.config }); } } self._initFromConfig(); callAfterBigBang(); }.bind(this)); } exports.MailUniverse = MailUniverse; MailUniverse.prototype = { ////////////////////////////////////////////////////////////////////////////// // Logging _enableCircularLogging: function() { this._logBacklog = []; logic.on('event', (event) => { this._logBacklog.push(event.toJSON()); // Remove any events we've kept for longer than MAX_LOG_BACKLOG_MS. var oldestTimeAllowed = Date.now() - MAX_LOG_BACKLOG_MS; while (this._logBacklog.length && this._logBacklog[0].time < oldestTimeAllowed) { this._logBacklog.shift(); } }); }, createLogBacklogRep: function() { return { type: 'logic', events: this._logBacklog }; }, dumpLogToDeviceStorage: function() { // This reuses the existing registration if one exists. var sendMessage = $router.registerCallbackType('devicestorage'); try { var blob = new Blob([JSON.stringify(this.createLogBacklogRep())], { type: 'application/json', endings: 'transparent' }); var filename = 'gem-log-' + Date.now() + '.json'; sendMessage('save', ['sdcard', blob, filename], function(success, err, savedFile) { if (success) console.log('saved log to "sdcard" devicestorage:', savedFile); else console.error('failed to save log to', filename); }); } catch(ex) { console.error('Problem dumping log to device storage:', ex, '\n', ex.stack); } }, ////////////////////////////////////////////////////////////////////////////// // Config / Settings /** * Perform initial initialization based on our configuration. */ _initFromConfig: function() { this._cronSync = new $cronsync.CronSync(this); }, /** * Return the subset of our configuration that the client can know about. */ exposeConfigForClient: function() { // eventually, iterate over a whitelist, but for now, it's easy... return { debugLogging: this.config.debugLogging }; }, modifyConfig: function(changes) { for (var key in changes) { var val = changes[key]; switch (key) { case 'debugLogging': break; default: continue; } this.config[key] = val; } this._db.saveConfig(this.config); this.__notifyConfig(); }, __notifyConfig: function() { var config = this.exposeConfigForClient(); for (var iBridge = 0; iBridge < this._bridges.length; iBridge++) { var bridge = this._bridges[iBridge]; bridge.notifyConfig(config); } }, setInteractive: function() { this._mode = 'interactive'; }, ////////////////////////////////////////////////////////////////////////////// _onConnectionChange: function(isOnline) { var wasOnline = this.online; /** * Are we online? AKA do we have actual internet network connectivity. * This should ideally be false behind a captive portal. This might also * end up temporarily false if we move to a 2-phase startup process. */ this.online = this._testModeFakeNavigator ? this._testModeFakeNavigator.onLine : isOnline; // Knowing when the app thinks it is online/offline is going to be very // useful for our console.log debug spew. console.log('Email knows that it is:', this.online ? 'online' : 'offline', 'and previously was:', wasOnline ? 'online' : 'offline'); /** * Do we want to minimize network usage? Right now, this is the same as * metered, but it's conceivable we might also want to set this if the * battery is low, we want to avoid stealing network/cpu from other * apps, etc. * * NB: We used to get this from navigator.connection.metered, but we can't * depend on that. */ this.minimizeNetworkUsage = true; /** * Is there a marginal cost to network usage? This is intended to be used * for UI (decision) purposes where we may want to prompt before doing * things when bandwidth is metered, but not when the user is on comparably * infinite wi-fi. * * NB: We used to get this from navigator.connection.metered, but we can't * depend on that. */ this.networkCostsMoney = true; if (!wasOnline && this.online) { // - check if we have any pending actions to run and run them if so. for (var iAcct = 0; iAcct < this.accounts.length; iAcct++) { this._resumeOpProcessingForAccount(this.accounts[iAcct]); } } }, /** * Helper function to wrap calls to account.runOp for local operations; done * only for consistency with `_dispatchServerOpForAccount`. */ _dispatchLocalOpForAccount: function(account, op) { var queues = this._opsByAccount[account.id]; queues.active = true; var mode; switch (op.lifecycle) { case 'do': mode = 'local_do'; op.localStatus = 'doing'; break; case 'undo': mode = 'local_undo'; op.localStatus = 'undoing'; break; default: throw new Error('Illegal lifecycle state for local op'); } account.runOp( op, mode, this._localOpCompleted.bind(this, account, op)); }, /** * Helper function to wrap calls to account.runOp for server operations since * it now gets more complex with 'check' mode. */ _dispatchServerOpForAccount: function(account, op) { var queues = this._opsByAccount[account.id]; queues.active = true; var mode = op.lifecycle; if (op.serverStatus === 'check') mode = 'check'; op.serverStatus = mode + 'ing'; account.runOp( op, mode, this._serverOpCompleted.bind(this, account, op)); }, /** * Start processing ops for an account if it's able and has ops to run. */ _resumeOpProcessingForAccount: function(account) { var queues = this._opsByAccount[account.id]; if (!account.enabled) return; // Nothing to do if there's a local op running if (!queues.local.length && queues.server.length && // (it's possible there is still an active job right now) (queues.server[0].serverStatus !== 'doing' && queues.server[0].serverStatus !== 'undoing')) { var op = queues.server[0]; this._dispatchServerOpForAccount(account, op); } }, /** * Return true if there are server jobs that are currently running or will run * imminently. * * It's possible for this method to be called during the cleanup stage of the * last job in the queue. It was intentionally decided to not try and be * clever in that case because the job could want to be immediately * rescheduled. Also, propagating the data to do that turned out to involve a * lot of sketchy manual propagation. * * If you have some logic you want to trigger when the server jobs have * all been sufficiently used up, you can use `waitForAccountOps` or add * logic to the account's `allOperationsCompleted` method. */ areServerJobsWaiting: function(account) { var queues = this._opsByAccount[account.id]; if (!account.enabled) { return false; } return !!queues.server.length; }, registerBridge: function(mailBridge) { this._bridges.push(mailBridge); }, unregisterBridge: function(mailBridge) { var idx = this._bridges.indexOf(mailBridge); if (idx !== -1) this._bridges.splice(idx, 1); }, learnAboutAccount: function(details) { var configurator = new $acctcommon.Autoconfigurator(); return configurator.learnAboutAccount(details); }, tryToCreateAccount: function mu_tryToCreateAccount(userDetails, domainInfo, callback) { if (!this.online) { callback('offline'); return; } if (!userDetails.forceCreate) { for (var i = 0; i < this.accounts.length; i++) { if (userDetails.emailAddress === this.accounts[i].identities[0].address) { callback('user-account-exists'); return; } } } if (domainInfo) { $acctcommon.tryToManuallyCreateAccount(this, userDetails, domainInfo, callback); } else { // XXX: store configurator on this object so we can abort the connections // if necessary. var configurator = new $acctcommon.Autoconfigurator(); configurator.tryToCreateAccount(this, userDetails, callback); } }, /** * Shutdown the account, forget about it, nuke associated database entries. */ deleteAccount: function(accountId) { var savedEx = null; var account = this._accountsById[accountId]; try { account.accountDeleted(); } catch (ex) { // save the failure until after we have done other cleanup. savedEx = ex; } this._db.deleteAccount(accountId); delete this._accountsById[accountId]; var idx = this.accounts.indexOf(account); this.accounts.splice(idx, 1); for (var i = 0; i < account.identities.length; i++) { var identity = account.identities[i]; idx = this.identities.indexOf(identity); this.identities.splice(idx, 1); delete this._identitiesById[identity.id]; } delete this._opsByAccount[accountId]; delete this._opCompletionListenersByAccount[accountId]; this.__notifyRemovedAccount(accountId); if (savedEx) throw savedEx; }, saveAccountDef: function(accountDef, folderInfo, callback) { this._db.saveAccountDef(this.config, accountDef, folderInfo, callback); var account = this.getAccountForAccountId(accountDef.id); // Make sure syncs are still accurate, since syncInterval // could have changed. if (this._cronSync) { this._cronSync.ensureSync(); } // If account exists, notify of modification. However on first // save, the account does not exist yet. if (account) this.__notifyModifiedAccount(account); }, /** * Instantiate an account from the persisted representation. * Asynchronous. Calls callback with the account object. */ _loadAccount: function mu__loadAccount(accountDef, folderInfo, receiveProtoConn, callback) { $acctcommon.accountTypeToClass(accountDef.type, function (constructor) { if (!constructor) { logic(this, 'badAccountType', { type: accountDef.type }); return; } var account = new constructor(this, accountDef, folderInfo, this._db, receiveProtoConn); this.accounts.push(account); this._accountsById[account.id] = account; this._opsByAccount[account.id] = { active: false, local: [], server: [], deferred: [] }; this._opCompletionListenersByAccount[account.id] = null; for (var iIdent = 0; iIdent < accountDef.identities.length; iIdent++) { var identity = accountDef.identities[iIdent]; this.identities.push(identity); this._identitiesById[identity.id] = identity; } this.__notifyAddedAccount(account); // - issue a (non-persisted) syncFolderList if needed var timeSinceLastFolderSync = Date.now() - account.meta.lastFolderSyncAt; if (timeSinceLastFolderSync >= $syncbase.SYNC_FOLDER_LIST_EVERY_MS) this.syncFolderList(account); // - check for mutations that still need to be processed // This will take care of deferred mutations too because they are still // maintained in this list. for (var i = 0; i < account.mutations.length; i++) { var op = account.mutations[i]; if (op.lifecycle !== 'done' && op.lifecycle !== 'undone' && op.lifecycle !== 'moot') { // For localStatus, we currently expect it to be consistent with the // state of the folder's database. We expect this to be true going // forward and as we make changes because when we save the account's // operation status, we should also be saving the folder changes at the // same time. // // The same cannot be said for serverStatus, so we need to check. See // comments about operations elsewhere (currently in imap/jobs.js). op.serverStatus = 'check'; this._queueAccountOp(account, op); } } account.upgradeFolderStoragesIfNeeded(); callback(account); }.bind(this)); }, /** * Self-reporting by an account that it is experiencing difficulties. * * We mutate its state for it, and generate a notification if this is a new * problem. For problems that require user action, we additionally generate * a bad login notification. * * @param account * @param {string} problem * @param {'incoming'|'outgoing'} whichSide */ __reportAccountProblem: function(account, problem, whichSide) { var suppress = false; // nothing to do if the problem is already known if (account.problems.indexOf(problem) !== -1) { suppress = true; } logic(this, 'reportProblem', { problem: problem, suppress: suppress, accountId: account.id }); if (suppress) { return; } account.problems.push(problem); account.enabled = false; this.__notifyModifiedAccount(account); switch (problem) { case 'bad-user-or-pass': case 'needs-oauth-reauth': case 'bad-address': case 'imap-disabled': this.__notifyBadLogin(account, problem, whichSide); break; } }, __removeAccountProblem: function(account, problem) { var idx = account.problems.indexOf(problem); if (idx === -1) return; account.problems.splice(idx, 1); account.enabled = (account.problems.length === 0); this.__notifyModifiedAccount(account); if (account.enabled) this._resumeOpProcessingForAccount(account); }, clearAccountProblems: function(account) { logic(this, 'clearAccountProblems', { accountId: account.id }); // TODO: this would be a great time to have any slices that had stalled // syncs do whatever it takes to make them happen again. account.enabled = true; account.problems = []; this._resumeOpProcessingForAccount(account); }, // expects (account, problem, whichSide) __notifyBadLogin: makeBridgeFn('notifyBadLogin'), // expects (account) __notifyAddedAccount: makeBridgeFn('notifyAccountAdded'), // expects (account) __notifyModifiedAccount: makeBridgeFn('notifyAccountModified'), // expects (accountId) __notifyRemovedAccount: makeBridgeFn('notifyAccountRemoved'), // expects (account, folderMeta) __notifyAddedFolder: makeBridgeFn('notifyFolderAdded'), // expects (account, folderMeta) __notifyModifiedFolder: makeBridgeFn('notifyFolderModified'), // expects (account, folderMeta) __notifyRemovedFolder: makeBridgeFn('notifyFolderRemoved'), // expects (suid, detail, body) __notifyModifiedBody: makeBridgeFn('notifyBodyModified'), ////////////////////////////////////////////////////////////////////////////// // cronsync Stuff // expects (accountIds) __notifyStartedCronSync: makeBridgeFn('notifyCronSyncStart'), // expects (accountsResults) __notifyStoppedCronSync: makeBridgeFn('notifyCronSyncStop'), // __notifyBackgroundSendStatus expects { // suid: messageSuid, // accountId: accountId, // sendFailures: (integer), // state: 'pending', 'sending', 'error', 'success', or 'syncDone' // emitNotifications: Boolean, // err: (if applicable), // badAddresses: (if applicable) // } __notifyBackgroundSendStatus: makeBridgeFn('notifyBackgroundSendStatus'), ////////////////////////////////////////////////////////////////////////////// // Lifetime Stuff /** * Write the current state of the universe to the database. */ saveUniverseState: function(callback) { var curTrans = null; var latch = $allback.latch(); logic(this, 'saveUniverseState_begin'); for (var iAcct = 0; iAcct < this.accounts.length; iAcct++) { var account = this.accounts[iAcct]; curTrans = account.saveAccountState(curTrans, latch.defer(account.id), 'saveUniverse'); } latch.then(function() { logic(this, 'saveUniverseState_end'); if (callback) { callback(); }; }.bind(this)); }, /** * Shutdown all accounts; this is currently for the benefit of unit testing. * We expect our app to operate in a crash-only mode of operation where a * clean shutdown means we get a heads-up, put ourselves offline, and trigger a * state save before we just demand that our page be closed. That's future * work, of course. * * If a callback is provided, a cleaner shutdown will be performed where we * wait for all current IMAP connections to be be shutdown by the server * before invoking the callback. */ shutdown: function(callback) { var waitCount = this.accounts.length; // (only used if a 'callback' is passed) function accountShutdownCompleted() { if (--waitCount === 0) callback(); } for (var iAcct = 0; iAcct < this.accounts.length; iAcct++) { var account = this.accounts[iAcct]; // only need to pass our handler if clean shutdown is desired account.shutdown(callback ? accountShutdownCompleted : null); } if (this._cronSync) { this._cronSync.shutdown(); } this._db.close(); if (!this.accounts.length) callback(); }, ////////////////////////////////////////////////////////////////////////////// // Lookups: Account, Folder, Identity getAccountForAccountId: function mu_getAccountForAccountId(accountId) { return this._accountsById[accountId]; }, /** * Given a folder-id, get the owning account. */ getAccountForFolderId: function mu_getAccountForFolderId(folderId) { var accountId = folderId.substring(0, folderId.indexOf('/')), account = this._accountsById[accountId]; return account; }, /** * Given a message's sufficiently unique identifier, get the owning account. */ getAccountForMessageSuid: function mu_getAccountForMessageSuid(messageSuid) { var accountId = messageSuid.substring(0, messageSuid.indexOf('/')), account = this._accountsById[accountId]; return account; }, getFolderStorageForFolderId: function mu_getFolderStorageForFolderId( folderId) { var account = this.getAccountForFolderId(folderId); return account.getFolderStorageForFolderId(folderId); }, getFolderStorageForMessageSuid: function mu_getFolderStorageForFolderId( messageSuid) { var folderId = messageSuid.substring(0, messageSuid.lastIndexOf('/')), account = this.getAccountForFolderId(folderId); return account.getFolderStorageForFolderId(folderId); }, getAccountForSenderIdentityId: function mu_getAccountForSenderIdentityId( identityId) { var accountId = identityId.substring(0, identityId.indexOf('/')), account = this._accountsById[accountId]; return account; }, getIdentityForSenderIdentityId: function mu_getIdentityForSenderIdentityId( identityId) { return this._identitiesById[identityId]; }, ////////////////////////////////////////////////////////////////////////////// // Message Mutation and Undoing /** * Partitions messages by account. Accounts may want to partition things * further, such as by folder, but we leave that up to them since not all * may require it. (Ex: activesync and gmail may be able to do things * that way.) */ _partitionMessagesByAccount: function(messageNamers, targetAccountId) { var results = [], acctToMsgs = {}; for (var i = 0; i < messageNamers.length; i++) { var messageNamer = messageNamers[i], messageSuid = messageNamer.suid, accountId = messageSuid.substring(0, messageSuid.indexOf('/')); if (!acctToMsgs.hasOwnProperty(accountId)) { var messages = [messageNamer]; results.push({ account: this._accountsById[accountId], messages: messages, crossAccount: (targetAccountId && targetAccountId !== accountId), }); acctToMsgs[accountId] = messages; } else { acctToMsgs[accountId].push(messageNamer); } } return results; }, /** * Put an operation in the deferred mutations queue and ensure the deferred * operation timer is active. The deferred queue is persisted to disk too * and transferred across to the non-deferred queue at account-load time. */ _deferOp: function(account, op) { this._opsByAccount[account.id].deferred.push(op.longtermId); if (this._deferredOpTimeout !== null) this._deferredOpTimeout = window.setTimeout( this._boundQueueDeferredOps, $syncbase.DEFERRED_OP_DELAY_MS); }, /** * Enqueue all deferred ops; invoked by the setTimeout scheduled by * `_deferOp`. We use a single timeout across all accounts, so the duration * of the defer delay can vary a bit, but our goal is just to avoid deferrals * turning into a tight loop that pounds the server, nothing fancier. */ _queueDeferredOps: function() { this._deferredOpTimeout = null; // If not in 'interactive' mode, then this is just a short // 'cron' existence that needs to shut down soon. Wait one // more cycle in case the app switches over to 'interactive' // in the meantime. if (this._mode !== 'interactive') { console.log('delaying deferred op since mode is ' + this._mode); this._deferredOpTimeout = window.setTimeout( this._boundQueueDeferredOps, $syncbase.DEFERRED_OP_DELAY_MS); return; } for (var iAccount = 0; iAccount < this.accounts.length; iAccount++) { var account = this.accounts[iAccount], queues = this._opsByAccount[account.id]; // we need to mutate in-place, so concat is not an option while (queues.deferred.length) { var op = queues.deferred.shift(); // There is no need to enqueue the operation if: // - It's already enqueued because someone called undo // - Undo got called and that ran to completion if (queues.server.indexOf(op) === -1 && op.lifecycle !== 'undo') this._queueAccountOp(account, op); } } }, /** * A local op finished; figure out what the error means, perform any requested * saves, and *only after the saves complete*, issue any appropriate callback * and only then start the next op. */ _localOpCompleted: function(account, op, err, resultIfAny, accountSaveSuggested) { var queues = this._opsByAccount[account.id], serverQueue = queues.server, localQueue = queues.local; var removeFromServerQueue = false, completeOp = false, wasMode = 'local_' + op.localStatus.slice(0, -3); if (err) { switch (err) { // Only defer is currently supported as a recoverable local failure // type. case 'defer': if (++op.tryCount < $syncbase.MAX_OP_TRY_COUNT) { logic(this, 'opDeferred', { type: op.type, longtermId: op.longtermId }); this._deferOp(account, op); removeFromServerQueue = true; break; } // fall-through to an error default: logic(this, 'opGaveUp', { type: op.type, longtermId: op.longtermId }); op.lifecycle = 'moot'; op.localStatus = 'unknown'; op.serverStatus = 'moot'; removeFromServerQueue = true; completeOp = true; break; } // Do not save if this was an error. accountSaveSuggested = false; } else { switch (op.localStatus) { case 'doing': op.localStatus = 'done'; // We have introduced the ability for a local op to decide that it // no longer wants a server operation to happen. It accomplishes this // by marking the serverStatus as skip, which we then process and // convert to 'n/a'. This is intended to be done by the local job // right before returning so the value doesn't get surfaced elsewhere. // Some might ask why this isn't some type of explicit return value. // To those people I say, "Good point, shut up." I might then go on // to say that the v3 refactor will likely deal with this and that's // real soon. if (op.serverStatus === 'skip') { removeFromServerQueue = true; op.serverStatus = 'n/a'; accountSaveSuggested = true; // this op change needs a save! } if (op.serverStatus === 'n/a') { op.lifecycle = 'done'; completeOp = true; } break; case 'undoing': op.localStatus = 'undone'; if (op.serverStatus === 'skip') { removeFromServerQueue = true; op.serverStatus = 'n/a'; accountSaveSuggested = true; // this op change needs a save! } if (op.serverStatus === 'n/a') { op.lifecycle = 'undone'; completeOp = true; } break; } } if (removeFromServerQueue) { var idx = serverQueue.indexOf(op); if (idx !== -1) serverQueue.splice(idx, 1); } localQueue.shift(); console.log('runOp_end(' + wasMode + ': ' + JSON.stringify(op).substring(0, 160) + ')\n'); logic(account, 'runOp_end', { mode: wasMode, type: op.type, error: err, op: op }); // Complete the asynchronous log event pertaining to 'runOp'. if (op._logicAsyncEvent) { if (err) { op._logicAsyncEvent.reject(err); } else { op._logicAsyncEvent.resolve(); } } var callback; if (completeOp) { if (this._opCallbacks.hasOwnProperty(op.longtermId)) { callback = this._opCallbacks[op.longtermId]; delete this._opCallbacks[op.longtermId]; } } if (accountSaveSuggested) { account.saveAccountState( null, this._startNextOp.bind(this, account, callback, op, err, resultIfAny), 'localOp:' + op.type); return; } this._startNextOp(account, callback, op, err, resultIfAny); }, /** * A server op finished; figure out what the error means, perform any * requested saves, and *only after the saves complete*, issue any appropriate * callback and only then start the next op. * * @args[ * @param[account[ * @param[op]{ * The operation. * } * @param[err @oneof[ * @case[null]{ * Success! * } * @case['defer']{ * The resource was unavailable, but might be available again in the * future. Defer the operation to be run in the future by putting it on * a deferred list that will get re-added after an arbitrary timeout. * This does not imply that a check operation needs to be run. This * reordering violates our general ordering guarantee; we could be * better if we made sure to defer all other operations that can touch * the same resource, but that's pretty complex. * * Deferrals do boost the tryCount; our goal with implementing this is * to support very limited * } * @case['aborted-retry']{ * The operation was started, but we lost the connection before we * managed to accomplish our goal. Run a check operation then run the * operation again depending on what 'check' says. * * 'defer' should be used instead if it's known that no mutations could * have been perceived by the server, etc. * } * @case['failure-give-up']{ * Something is broken in a way we don't really understand and it's * unlikely that retrying is actually going to accomplish anything. * Although we mark the status 'moot', this is a more sinister failure * that should generate debugging/support data when appropriate. * } * @case['moot']{ * The operation no longer makes any sense. * } * @default{ * Some other type of error occurred. This gets treated the same as * aborted-retry * } * ]] * @param[resultIfAny]{ * A result to be relayed to the listening callback for the operation, if * there is one. This is intended to be used for things like triggering * attachment downloads where it would be silly to make the callback * re-get the changed data itself. * } * @param[accountSaveSuggested #:optional Boolean]{ * Used to indicate that this has changed the state of the system and a * save should be performed at some point in the future. * } * ] */ _serverOpCompleted: function(account, op, err, resultIfAny, accountSaveSuggested) { var queues = this._opsByAccount[account.id], serverQueue = queues.server, localQueue = queues.local; var scope = logic.subscope(this, { type: op.type, longtermId: op.longtermId }); if (serverQueue[0] !== op) logic(scope, 'opInvariantFailure'); // Should we attempt to retry (but fail if tryCount is reached)? var maybeRetry = false; // Pop the event off the queue? (avoid bugs versus multiple calls) var consumeOp = true; // Generate completion notifications for the op? var completeOp = true; var wasMode = op.serverStatus.slice(0, -3); if (err) { switch (err) { case 'defer': if (++op.tryCount < $syncbase.MAX_OP_TRY_COUNT) { // Defer the operation if we still want to do the thing, but skip // deferring if we are now trying to undo the thing. if (op.serverStatus === 'doing' && op.lifecycle === 'do') { logic(scope, 'opDeferred'); this._deferOp(account, op); } // remove the op from the queue, but don't mark it completed completeOp = false; } else { op.lifecycle = 'moot'; op.serverStatus = 'moot'; } break; case 'aborted-retry': op.tryCount++; maybeRetry = true; break; default: // (unknown case) op.tryCount += $syncbase.OP_UNKNOWN_ERROR_TRY_COUNT_INCREMENT; maybeRetry = true; break; case 'failure-give-up': logic(scope, 'opGaveUp'); // we complete the op, but the error flag is propagated op.lifecycle = 'moot'; op.serverStatus = 'moot'; break; case 'moot': logic(scope, 'opMooted'); // we complete the op, but the error flag is propagated op.lifecycle = 'moot'; op.serverStatus = 'moot'; break; } } else { switch (op.serverStatus) { case 'checking': // Update the status, and figure out if there is any work to do based // on our desire. switch (resultIfAny) { case 'checked-notyet': case 'coherent-notyet': op.serverStatus = null; break; case 'idempotent': if (op.lifecycle === 'do' || op.lifecycle === 'done') op.serverStatus = null; else op.serverStatus = 'done'; break; case 'happened': op.serverStatus = 'done'; break; case 'moot': op.lifecycle = 'moot'; op.serverStatus = 'moot'; break; // this is the same thing as defer. case 'bailed': logic(scope, 'opDeferred'); this._deferOp(account, op); completeOp = false; break; } break; case 'doing': op.serverStatus = 'done'; // lifecycle may have changed to 'undo'; don't mutate if so if (op.lifecycle === 'do') op.lifecycle = 'done'; break; case 'undoing': op.serverStatus = 'undone'; // this will always be true until we gain 'redo' functionality if (op.lifecycle === 'undo') op.lifecycle = 'undone'; break; } // If we still want to do something, then don't consume the op. if (op.lifecycle === 'do' || op.lifecycle === 'undo') consumeOp = false; } if (maybeRetry) { if (op.tryCount < $syncbase.MAX_OP_TRY_COUNT) { // We're still good to try again, but we will need to check the status // first. op.serverStatus = 'check'; consumeOp = false; } else { logic(scope, 'opTryLimitReached'); // we complete the op, but the error flag is propagated op.lifecycle = 'moot'; op.serverStatus = 'moot'; } } if (consumeOp) serverQueue.shift(); console.log('runOp_end(' + wasMode + ': ' + JSON.stringify(op).substring(0, 160) + ')\n'); logic(account, 'runOp_end', { mode: wasMode, type: op.type, error: err, op: op }); // Complete the asynchronous log event pertaining to 'runOp'. if (op._logicAsyncEvent) { if (err) { op._logicAsyncEvent.reject(err); } else { op._logicAsyncEvent.resolve(); } } // Some completeOp callbacks want to wait for account // save but they are triggered before save is attempted, // for the account to properly trigger runAfterSaves // callbacks, so set a flag indicating save state here. if (accountSaveSuggested) account._saveAccountIsImminent = true; var callback; if (completeOp) { if (this._opCallbacks.hasOwnProperty(op.longtermId)) { callback = this._opCallbacks[op.longtermId]; delete this._opCallbacks[op.longtermId]; } // This is a suggestion; in the event of high-throughput on operations, // we probably don't want to save the account every tick, etc. if (accountSaveSuggested) { account._saveAccountIsImminent = false; account.saveAccountState( null, this._startNextOp.bind(this, account, callback, op, err, resultIfAny), 'serverOp:' + op.type); return; } } this._startNextOp(account, callback, op, err, resultIfAny); }, /** * Shared code for _localOpCompleted and _serverOpCompleted to figure out what * to do next *after* any account save has completed, including invoking * callbacks. See bug https://bugzil.la/1039007 for rationale as to why we * think it makes sense to defer the callbacks or to provide new reasons why * we should change this behaviour. * * It used to be that we would trigger saves without waiting for them to * complete with the theory that this would allow us to generally be more * efficient without losing correctness since the IndexedDB transaction model * is strong and takes care of data dependency issues for us. However, for * both testing purposes and with some new concerns over correctness issues, * it's now making sense to wait on the transaction to commit. There are * potentially some memory-use wins from waiting for the transaction to * complete, especially if we imagine some particularly pathological * situations. * * @param account * @param {Function} [callback] * The callback associated with the last operation. May be omitted. If * provided then all of the following arguments must also be provided. * @param [lastOp] * @param [err] * @param [result] */ _startNextOp: function(account, callback, lastOp, err, result) { var queues = this._opsByAccount[account.id], serverQueue = queues.server, localQueue = queues.local; var op; if (callback) { try { callback(err, result, account, lastOp); } catch(ex) { console.log(ex.message, ex.stack); logic(this, 'opCallbackErr', { type: lastOp.type }); } } // We must hold off on freeing up queue.active until after we have // completed processing and called the callback, just as we do in // _localOpCompleted. This allows `callback` to safely schedule // new jobs without interfering with the scheduling we're going to // do immediately below. queues.active = false; if (localQueue.length) { op = localQueue[0]; this._dispatchLocalOpForAccount(account, op); } else if (serverQueue.length && this.online && account.enabled) { op = serverQueue[0]; this._dispatchServerOpForAccount(account, op); } // We finished all the operations! Woo! else { // Notify listeners if (this._opCompletionListenersByAccount[account.id]) { this._opCompletionListenersByAccount[account.id](account); this._opCompletionListenersByAccount[account.id] = null; } logic(this, 'allOpsCompleted', { accountId: account.id }); // - Tell the account so it can clean-up its connections, etc. // (We do this after notifying listeners for the connection cleanup case // so that if the listener wants to schedule new activity, it can do so // without having to wastefully establish a new connection.) account.allOperationsCompleted(); } }, /** * Enqueue an operation for processing. The local mutation is enqueued if it * has not yet been run. The server piece is always enqueued. * * @args[ * @param[account] * @param[op SerializedMutation]{ * Note that a `null` longtermId should be passed in if the operation * should be persisted, and a 'session' string if the operation should * not be persisted. In both cases, a longtermId will be allocated, * } * @param[optionalCallback #:optional Function]{ * A callback to invoke when the operation completes. Callbacks are * obviously not capable of being persisted and are merely best effort. * } * ] */ _queueAccountOp: function(account, op, optionalCallback) { var queues = this._opsByAccount[account.id]; // Log the op for debugging assistance // TODO: Create a real logger event; this will require updating existing // tests and so is not sufficiently trivial to do at this time. console.log('queueOp', account.id, op.type, 'pre-queues:', 'local:', queues.local.length, 'server:', queues.server.length); // - Name the op, register callbacks if (op.longtermId === null) { // mutation job must be persisted until completed otherwise bad thing // will happen. op.longtermId = account.id + '/' + $a64.encodeInt(account.meta.nextMutationNum++); account.mutations.push(op); // Clear out any completed/dead operations that put us over the undo // threshold. while (account.mutations.length > MAX_MUTATIONS_FOR_UNDO && (account.mutations[0].lifecycle === 'done') || (account.mutations[0].lifecycle === 'undone') || (account.mutations[0].lifecycle === 'moot')) { account.mutations.shift(); } } else if (op.longtermId === 'session') { op.longtermId = account.id + '/' + $a64.encodeInt(account.meta.nextMutationNum++); } if (optionalCallback) this._opCallbacks[op.longtermId] = optionalCallback; // - Enqueue // Local processing needs to happen if we're not in the right local state. if (!this._testModeDisablingLocalOps && ((op.lifecycle === 'do' && op.localStatus === null) || (op.lifecycle === 'undo' && op.localStatus !== 'undone' && op.localStatus !== 'unknown'))) queues.local.push(op); if (op.serverStatus !== 'n/a' && op.serverStatus !== 'moot') queues.server.push(op); // If there is already something active, don't do anything! if (queues.active) { } else if (queues.local.length) { // Only actually dispatch if there is only the op we just (maybe). if (queues.local.length === 1 && queues.local[0] === op) this._dispatchLocalOpForAccount(account, op); // else: we grabbed control flow to avoid the server queue running } else if (queues.server.length === 1 && queues.server[0] === op && this.online && account.enabled) { this._dispatchServerOpForAccount(account, op); } return op.longtermId; }, waitForAccountOps: function(account, callback) { var queues = this._opsByAccount[account.id]; if (!queues.active && queues.local.length === 0 && (queues.server.length === 0 || !this.online || !account.enabled)) callback(); else this._opCompletionListenersByAccount[account.id] = callback; }, syncFolderList: function(account, callback) { this._queueAccountOp( account, { type: 'syncFolderList', longtermId: 'session', lifecycle: 'do', localStatus: 'done', serverStatus: null, tryCount: 0, humanOp: 'syncFolderList' }, callback); }, /** * Schedule a purge of the excess messages from the given folder. This * currently only makes sense for IMAP accounts and will automatically be * called by the FolderStorage and its owning account when a sufficient * number of blocks have been allocated by the storage. */ purgeExcessMessages: function(account, folderId, callback) { this._queueAccountOp( account, { type: 'purgeExcessMessages', longtermId: 'session', lifecycle: 'do', localStatus: null, serverStatus: 'n/a', tryCount: 0, humanOp: 'purgeExcessMessages', folderId: folderId }, callback); }, /** * Download entire bodyRep(s) representation. */ downloadMessageBodyReps: function(suid, date, callback) { var account = this.getAccountForMessageSuid(suid); this._queueAccountOp( account, { type: 'downloadBodyReps', longtermId: 'session', lifecycle: 'do', localStatus: 'done', serverStatus: null, tryCount: 0, humanOp: 'downloadBodyReps', messageSuid: suid, messageDate: date }, callback ); }, downloadBodies: function(messages, options, callback) { if (typeof(options) === 'function') { callback = options; options = null; } var self = this; var pending = 0; function next() { if (!--pending) { callback(); } } this._partitionMessagesByAccount(messages, null).forEach(function(x) { pending++; self._queueAccountOp( x.account, { type: 'downloadBodies', longtermId: 'session', // don't persist this job. lifecycle: 'do', localStatus: 'done', serverStatus: null, tryCount: 0, humanOp: 'downloadBodies', messages: x.messages, options: options }, next ); }); }, /** * Download one or more related-part or attachments from a message. * Attachments are named by their index because the indices are stable and * flinging around non-authoritative copies of the structures might lead to * some (minor) confusion. * * This request is persistent although the callback will obviously be * discarded in the event the app is killed. * * @param {String[]} relPartIndices * The part identifiers of any related parts to be saved to IndexedDB. * @param {String[]} attachmentIndices * The part identifiers of any attachment parts to be saved to * DeviceStorage. For each entry in this array there should be a * corresponding boolean in registerWithDownloadManager. * @param {Boolean[]} registerAttachments * An array of booleans corresponding to each entry in attachmentIndices * indicating whether the download should be registered with the download * manager. */ downloadMessageAttachments: function(messageSuid, messageDate, relPartIndices, attachmentIndices, registerAttachments, callback) { var account = this.getAccountForMessageSuid(messageSuid); var longtermId = this._queueAccountOp( account, { type: 'download', longtermId: null, lifecycle: 'do', localStatus: null, serverStatus: null, tryCount: 0, humanOp: 'download', messageSuid: messageSuid, messageDate: messageDate, relPartIndices: relPartIndices, attachmentIndices: attachmentIndices, registerAttachments: registerAttachments }, callback); }, modifyMessageTags: function(humanOp, messageSuids, addTags, removeTags) { var self = this, longtermIds = []; this._partitionMessagesByAccount(messageSuids, null).forEach(function(x) { var longtermId = self._queueAccountOp( x.account, { type: 'modtags', longtermId: null, lifecycle: 'do', localStatus: null, serverStatus: null, tryCount: 0, humanOp: humanOp, messages: x.messages, addTags: addTags, removeTags: removeTags, // how many messages have had their tags changed already. progress: 0, }); longtermIds.push(longtermId); }); return longtermIds; }, moveMessages: function(messageSuids, targetFolderId, callback) { var self = this, longtermIds = [], targetFolderAccount = this.getAccountForFolderId(targetFolderId); var latch = $allback.latch(); this._partitionMessagesByAccount(messageSuids, null).forEach(function(x,i) { // TODO: implement cross-account moves and then remove this constraint // and instead schedule the cross-account move. if (x.account !== targetFolderAccount) throw new Error('cross-account moves not currently supported!'); // If the move is entirely local-only (i.e. folders that will // never be synced to the server), we don't need to run the // server side of the job. // // When we're moving a message between an outbox and // localdrafts, we need the operation to succeed even if we're // offline, and we also need to receive the "moveMap" returned // by the local side of the operation, so that the client can // call "editAsDraft" on the moved message. // // TODO: When we have server-side 'draft' folder support, we // actually still want to run the server side of the operation, // but we won't want to wait for it to complete. Maybe modify // the job system to pass back localResult and serverResult // independently, or restructure the way we move outbox messages // back to the drafts folder. var targetStorage = targetFolderAccount.getFolderStorageForFolderId(targetFolderId); // If any of the sourceStorages (or targetStorage) is not // local-only, we can stop looking. var isLocalOnly = targetStorage.isLocalOnly; for (var j = 0; j < x.messages.length && isLocalOnly; j++) { var sourceStorage = self.getFolderStorageForMessageSuid(x.messages[j].suid); if (!sourceStorage.isLocalOnly) { isLocalOnly = false; } } var longtermId = self._queueAccountOp( x.account, { type: 'move', longtermId: null, lifecycle: 'do', localStatus: null, serverStatus: isLocalOnly ? 'n/a' : null, tryCount: 0, humanOp: 'move', messages: x.messages, targetFolder: targetFolderId, }, latch.defer(i)); longtermIds.push(longtermId); }); // When the moves finish, they'll each pass back results of the // form [err, moveMap]. The moveMaps provide a mapping of // sourceSuid => targetSuid, allowing the client to point itself // to the moved messages. Since multiple moves would result in // multiple moveMap results, we combine them here into a single // result map. latch.then(function(results) { // results === [[err, moveMap], [err, moveMap], ...] var combinedMoveMap = {}; for (var key in results) { var moveMap = results[key][1]; for (var k in moveMap) { combinedMoveMap[k] = moveMap[k]; } } callback && callback(/* err = */ null, /* result = */ combinedMoveMap); }); return longtermIds; }, deleteMessages: function(messageSuids) { var self = this, longtermIds = []; this._partitionMessagesByAccount(messageSuids, null).forEach(function(x) { var longtermId = self._queueAccountOp( x.account, { type: 'delete', longtermId: null, lifecycle: 'do', localStatus: null, serverStatus: null, tryCount: 0, humanOp: 'delete', messages: x.messages }); longtermIds.push(longtermId); }); return longtermIds; }, /** * APPEND messages to an IMAP server without locally saving the messages. * This was originally an IMAP testing operation that was co-opted to be * used for saving sent messages in a corner-cutting fashion. (The right * thing for us to do would be to save the message locally too and deal with * the UID implications. But that is tricky.) * * See ImapAccount.saveSentMessage for more context. * * POP3's variation on this is saveSentDraft */ appendMessages: function(folderId, messages, callback) { var account = this.getAccountForFolderId(folderId); var longtermId = this._queueAccountOp( account, { type: 'append', // Don't persist. See ImapAccount.saveSentMessage for our rationale. longtermId: 'session', lifecycle: 'do', localStatus: 'done', serverStatus: null, tryCount: 0, humanOp: 'append', messages: messages, folderId: folderId, }, callback); return [longtermId]; }, /** * Save a sent POP3 message to the account's "sent" folder. See * Pop3Account.saveSentMessage for more information. * * IMAP's variation on this is appendMessages. * * @param folderId {FolderID} * @param sentSafeHeader {HeaderInfo} * The header ready to be added to the sent folder; suid issued and * everything. * @param sentSafeBody {BodyInfo} * The body ready to be added to the sent folder; attachment blobs stripped. * @param callback {function(err)} */ saveSentDraft: function(folderId, sentSafeHeader, sentSafeBody, callback) { var account = this.getAccountForMessageSuid(sentSafeHeader.suid); var longtermId = this._queueAccountOp( account, { type: 'saveSentDraft', // we can persist this since we have stripped the blobs longtermId: null, lifecycle: 'do', localStatus: null, serverStatus: 'n/a', tryCount: 0, humanOp: 'saveSentDraft', folderId: folderId, headerInfo: sentSafeHeader, bodyInfo: sentSafeBody }, callback); return [longtermId]; }, /** * Process the given attachment blob in slices into base64-encoded Blobs * that we store in IndexedDB (currently). This is a local-only operation. * * This function is implemented as a job/operation so it is inherently ordered * relative to other draft-related calls. But do keep in mind that you need * to make sure to not destroy the underlying storage for the Blob (ex: when * using DeviceStorage) until the callback has fired. */ attachBlobToDraft: function(account, existingNamer, attachmentDef, callback) { this._queueAccountOp( account, { type: 'attachBlobToDraft', // We don't persist the operation to disk in order to avoid having the // Blob we are attaching get persisted to IndexedDB. Better for the // disk I/O to be ours from the base64 encoded writes we do even if // there is a few seconds of data-loss-ish vulnerability. longtermId: 'session', lifecycle: 'do', localStatus: null, serverStatus: 'n/a', // local-only currently tryCount: 0, humanOp: 'attachBlobToDraft', existingNamer: existingNamer, attachmentDef: attachmentDef }, callback ); }, /** * Remove an attachment from a draft. This will not interrupt an active * attaching operation or moot a pending one. This is a local-only operation. */ detachAttachmentFromDraft: function(account, existingNamer, attachmentIndex, callback) { this._queueAccountOp( account, { type: 'detachAttachmentFromDraft', // This is currently non-persisted for symmetry with attachBlobToDraft // but could be persisted if we wanted. longtermId: 'session', lifecycle: 'do', localStatus: null, serverStatus: 'n/a', // local-only currently tryCount: 0, humanOp: 'detachAttachmentFromDraft', existingNamer: existingNamer, attachmentIndex: attachmentIndex }, callback ); }, /** * Save a new (local) draft or update an existing (local) draft. A new namer * is synchronously created and returned which will be the name for the draft * assuming the save completes successfully. * * This function is implemented as a job/operation so it is inherently ordered * relative to other draft-related calls. * * @method saveDraft * @param account * @param [existingNamer] {MessageNamer} * @param draftRep * @param callback {Function} * @return {MessageNamer} * */ saveDraft: function(account, existingNamer, draftRep, callback) { var draftsFolderMeta = account.getFirstFolderWithType('localdrafts'); var draftsFolderStorage = account.getFolderStorageForFolderId( draftsFolderMeta.id); var newId = draftsFolderStorage._issueNewHeaderId(); var newDraftInfo = { id: newId, suid: draftsFolderStorage.folderId + '/' + newId, // There are really 3 possible values we could use for this; when the // front-end initiates the draft saving, when we, the back-end observe and // enqueue the request (now), or when the draft actually gets saved to // disk. // // This value does get surfaced to the user, so we ideally want it to // occur within a few seconds of when the save is initiated. We do this // here right now because we have access to $date, and we should generally // be timely about receiving messages. date: $date.NOW(), }; this._queueAccountOp( account, { type: 'saveDraft', longtermId: null, lifecycle: 'do', localStatus: null, serverStatus: 'n/a', // local-only currently tryCount: 0, humanOp: 'saveDraft', existingNamer: existingNamer, newDraftInfo: newDraftInfo, draftRep: draftRep, }, callback ); return { suid: newDraftInfo.suid, date: newDraftInfo.date }; }, /** * Kick off a job to send pending outgoing messages. See the job * documentation regarding "sendOutboxMessages" for more details. * * @param {MailAccount} account * @param {MessageNamer} opts.beforeMessage * If provided, start with the first message older than this one. * (This is only used internally within the job itself.) * @param {string} opts.reason * Optional description, used for debugging. * @param {Boolean} opts.emitNotifications * True to pass along send status notifications to the model. */ sendOutboxMessages: function(account, opts, callback) { opts = opts || {}; console.log('outbox: sendOutboxMessages(', JSON.stringify(opts), ')'); // If we are not online, we won't actually kick off a job until we // come back online. Immediately fire a status notification // indicating that we are done attempting to sync for now. if (!this.online) { this.notifyOutboxSyncDone(account); // Fall through; we still want to queue the op. } // Do not attempt to check if the outbox is empty here. This op is // queued immediately after the client moves a message to the // outbox. The outbox may be empty here, but it might be filled // when the op runs. this._queueAccountOp( account, { type: 'sendOutboxMessages', longtermId: 'session', // Does not need to be persisted. lifecycle: 'do', localStatus: 'n/a', serverStatus: null, tryCount: 0, beforeMessage: opts.beforeMessage, emitNotifications: opts.emitNotifications, humanOp: 'sendOutboxMessages' }, callback); }, /** * Dispatch a notification to the frontend, indicating that we're * done trying to send messages from the outbox for now. */ notifyOutboxSyncDone: function(account) { this.__notifyBackgroundSendStatus({ accountId: account.id, state: 'syncDone' }); }, /** * Enable or disable Outbox syncing temporarily. For instance, you * will want to disable outbox syncing if the user is in "edit mode" * for the list of messages in the outbox folder. This setting does * not persist. */ setOutboxSyncEnabled: function(account, enabled, callback) { this._queueAccountOp( account, { type: 'setOutboxSyncEnabled', longtermId: 'session', // Does not need to be persisted. lifecycle: 'do', localStatus: null, serverStatus: 'n/a', // Local-only. outboxSyncEnabled: enabled, tryCount: 0, humanOp: 'setOutboxSyncEnabled' }, callback); }, /** * Delete an existing (local) draft. * * This function is implemented as a job/operation so it is inherently ordered * relative to other draft-related calls. */ deleteDraft: function(account, messageNamer, callback) { this._queueAccountOp( account, { type: 'deleteDraft', longtermId: null, lifecycle: 'do', localStatus: null, serverStatus: 'n/a', // local-only currently tryCount: 0, humanOp: 'deleteDraft', messageNamer: messageNamer }, callback ); }, /** * Create a folder that is the child/descendant of the given parent folder. * If no parent folder id is provided, we attempt to create a root folder, * but honoring the server's configured personal namespace if applicable. * * @param [AccountId] accountId * @param {String} [parentFolderId] * If null, place the folder at the top-level, otherwise place it under * the given folder. * @param {String} folderName * The (unencoded) name of the folder to be created. * @param {String} folderType * The gelam folder type we should think of this folder as. On servers * supporting SPECIAL-USE we will attempt to set the metadata server-side * as well. * @param {Boolean} containOtherFolders * Should this folder only contain other folders (and no messages)? * On some servers/backends, mail-bearing folders may not be able to * create sub-folders, in which case one would have to pass this. * @param {Function(err, folderMeta)} callback * A callback that gets called with the folderMeta of the successfully * created folder or null if there was an error. (The error code is also * provided as the first argument.) * ] */ createFolder: function(accountId, parentFolderId, folderName, folderType, containOtherFolders, callback) { var account = this.getAccountForAccountId(accountId); var longtermId = this._queueAccountOp( account, { type: 'createFolder', longtermId: null, lifecycle: 'do', localStatus: null, serverStatus: null, tryCount: 0, humanOp: 'createFolder', parentFolderId: parentFolderId, folderName: folderName, folderType: folderType, containOtherFolders: containOtherFolders }, callback); return [longtermId]; }, /** * Idempotently trigger the undo logic for the performed operation. Calling * undo on an operation that is already undone/slated for undo has no effect. */ undoMutation: function(longtermIds) { for (var i = 0; i < longtermIds.length; i++) { var longtermId = longtermIds[i], account = this.getAccountForFolderId(longtermId), // (it's fine) queues = this._opsByAccount[account.id]; for (var iOp = 0; iOp < account.mutations.length; iOp++) { var op = account.mutations[iOp]; if (op.longtermId === longtermId) { // There is nothing to do if we have already processed the request or // or the op has already been fully undone. if (op.lifecycle === 'undo' || op.lifecycle === 'undone') { continue; } // Queue an undo operation if we're already done. if (op.lifecycle === 'done') { op.lifecycle = 'undo'; this._queueAccountOp(account, op); continue; } // else op.lifecycle === 'do' // If we have not yet started processing the operation, we can // simply remove the operation from the local queue. var idx = queues.local.indexOf(op); if (idx !== -1) { op.lifecycle = 'undone'; queues.local.splice(idx, 1); continue; } // (the operation must have already been run locally, which means // that at the very least we need to local_undo, so queue it.) op.lifecycle = 'undo'; this._queueAccountOp(account, op); } } } }, /** * Trigger the necessary folder upgrade logic */ performFolderUpgrade: function(folderId, callback) { var account = this.getAccountForFolderId(folderId); this._queueAccountOp( account, { type: 'upgradeDB', longtermId: 'session', lifecycle: 'do', localStatus: null, serverStatus: 'n/a', tryCount: 0, humanOp: 'append', folderId: folderId }, callback ); } ////////////////////////////////////////////////////////////////////////////// }; }); // end define