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

419 lines
14 KiB
JavaScript
Executable file

/*global define, console */
/**
* Drives periodic synchronization, covering the scheduling, deciding what
* folders to sync, and generating notifications to relay to the UI. More
* specifically, we have two goals:
*
* 1) Generate notifications about new messages.
*
* 2) Cause the device to synchronize its offline store periodically with the
* server for general responsiveness and so the user can use the device
* offline.
*
* We use mozAlarm to schedule ourselves to wake up when our next
* synchronization should occur.
*
* All synchronization occurs in parallel because we want the interval that we
* force the device's radio into higher power modes to be as short as possible.
*
* This logic is part of the back-end, not the front-end. We want to notify
* the front-end of new messages, but we want the front-end to be the one that
* displays and services them to the user.
**/
define(function(require, exports) {
'use strict';
var logic = require('logic'),
router = require('./worker-router'),
mailslice = require('./mailslice'),
syncbase = require('./syncbase'),
allback = require( './allback');
function debug(str) {
console.log('cronsync: ' + str + '\n');
}
var SliceBridgeProxy = require('./slice_bridge_proxy').SliceBridgeProxy;
/**
* Create a specialized sync slice via clobbering that accumulates a list of
* new headers and invokes a callback when the sync has fully completed.
*
* Fully completed includes:
* - The account update has been fully saved to disk.
*
* New header semantics are:
* - Header is as new or newer than the newest header we previously knew about.
* Specifically we're using SINCE which is >=, so a message that arrived at
* the same second as the other message still counts as new. Because the new
* message will inherently have a higher id than the other message, this
* meets with our other ordering semantics, although I'm thinking it wasn't
* totally intentional.
* - Header is unread. (AKA Not \Seen)
*
* "Clobbering" in this case means this is a little hacky. What we do is:
* - Take a normal slice and hook it up to a normal SliceBridgeProxy, but
* give the proxy a fake bridge that never sends any data anywhere. This
* is reasonably future-proof/safe.
*
* - Put an onNewHeader method on the slice to accumulate the new headers.
* The new headers are all we care about. The rest of the headers loaded/etc.
* are boring to us and do not matter. However, there's relatively little
* memory or CPU overhead to letting that stuff get populated/retained since
* it's in memory already anyways and at worst we're only delaying the GC by
* a little bit. (This is not to say there aren't pathological situations
* possible, but they'd be largely the same if the user triggered the sync.
* The main difference cronsync currently will definitely not shrink the
* slice.
*
* - Clobber proy.sendStatus to know when the sync has completed via the
* same signal the front-end uses to know when the sync is over.
*
* You as the caller need to:
* - Make sure to kill the slice in a timely fashion after we invoke the
* callback. Since killing the slice can result in the connection immediately
* being closed, you want to make sure that if you're doing anything like
* scheduling snippet downloads that you do that first.
*/
function makeHackedUpSlice(storage, callback) {
var fakeBridgeThatEatsStuff = {
__sendMessage: function() {}
},
proxy = new SliceBridgeProxy(fakeBridgeThatEatsStuff, 'cron'),
slice = new mailslice.MailSlice(proxy, storage),
oldStatusMethod = proxy.sendStatus,
newHeaders = [];
slice.onNewHeader = function(header) {
console.log('onNewHeader: ' + header);
newHeaders.push(header);
};
proxy.sendStatus = function(status, requested, moreExpected,
progress, newEmailCount) {
// (maintain normal behaviour)
oldStatusMethod.apply(this, arguments);
// We do not want to declare victory until the sync process has fully
// completed which (significantly!) includes waiting for the save to have
// completed.
// (Only fire completion once.)
if (callback) {
switch (status) {
// normal success and failure
case 'synced':
case 'syncfailed':
// ActiveSync specific edge-case where syncFolderList has not yet
// completed. If the slice is still alive when syncFolderList completes
// the slice will auto-refresh itself. We don't want or need this,
// which is fine since we kill the slice in the callback.
case 'syncblocked':
try {
callback(newHeaders);
}
catch (ex) {
console.error('cronsync callback error:', ex, '\n', ex.stack);
callback = null;
throw ex;
}
callback = null;
break;
}
}
};
return slice;
}
/**
* The brains behind periodic account synchronization; only created by the
* universe once it has loaded its configuration and accounts.
*/
function CronSync(universe) {
this._universe = universe;
logic.defineScope(this, 'CronSync');
this._ensureSyncResolve = null;
this.sendCronSync = router.registerSimple('cronsync', (data) => {
var args = data.args;
switch (data.cmd) {
case 'alarm':
debug('received an alarm via a message handler');
this.onAlarm.apply(this, args);
break;
case 'syncEnsured':
debug('received an syncEnsured via a message handler');
this.onSyncEnsured.apply(this, args);
break;
}
});
this.sendCronSync('hello');
this.ensureSync();
}
exports.CronSync = CronSync;
CronSync.prototype = {
/**
* Makes sure there is a sync timer set up for all accounts.
*/
ensureSync: function() {
// Only execute ensureSync if it is not already in progress. Otherwise, due
// to async timing of mozAlarm setting, could end up with two sync tasks for
// the same ID.
if (this._ensureSyncResolve) {
return;
}
logic(this, 'ensureSync_begin');
this._ensureSyncPromise = new Promise((resolve) => {
// No error pathway for the bridge hop, so just tracking resolve.
this._ensureSyncResolve = resolve;
});
debug('ensureSync called');
var accounts = this._universe.accounts,
syncData = {};
accounts.forEach(function(account) {
// Store data by interval, use a more obvious string key instead of just
// stringifying a number, which could be confused with an array construct.
var interval = account.accountDef.syncInterval,
intervalKey = 'interval' + interval;
if (!syncData.hasOwnProperty(intervalKey)) {
syncData[intervalKey] = [];
}
syncData[intervalKey].push(account.id);
});
this.sendCronSync('ensureSync', [syncData]);
},
/**
* Called from cronsync-main once ensureSync as set any alarms needed. Need to
* wait for it before signaling sync is done because otherwise the app could
* get closed down before the alarm additions succeed.
*/
onSyncEnsured: function() {
this._ensureSyncResolve();
this._ensureSyncResolve = null;
logic(this, 'ensureSync_end');
},
/**
* Synchronize the given account. This fetches new messages for the inbox, and
* attempts to send pending outbox messages (if applicable). The callback
* occurs after both of those operations have completed.
*/
syncAccount: function(account) {
return new Promise((resolve) => {
var scope = logic.subscope(this, { accountId: account.id });
// - Skip syncing if we are offline or the account is disabled
if (!this._universe.online || !account.enabled) {
debug('syncAccount early exit: online: ' +
this._universe.online + ', enabled: ' + account.enabled);
logic(scope, 'syncSkipped');
resolve();
return;
}
var latch = allback.latch();
var inboxDone = latch.defer('inbox');
var inboxFolder = account.getFirstFolderWithType('inbox');
var storage = account.getFolderStorageForFolderId(inboxFolder.id);
// XXX check when the folder was most recently synchronized and skip this
// sync if it is sufficiently recent.
// - Initiate a sync of the folder covering the desired time range.
logic(scope, 'syncAccount_begin');
logic(scope, 'syncAccountHeaders_begin');
var slice = makeHackedUpSlice(storage, (newHeaders) => {
logic(scope, 'syncAccountHeaders_end', { headers: newHeaders });
// Reduce headers to the minimum number and data set needed for
// notifications.
var notifyHeaders = [];
newHeaders.some(function(header, i) {
notifyHeaders.push({
date: header.date,
from: header.author.name || header.author.address,
subject: header.subject,
accountId: account.id,
messageSuid: header.suid
});
if (i === syncbase.CRONSYNC_MAX_MESSAGES_TO_REPORT_PER_ACCOUNT - 1) {
return true;
}
});
if (newHeaders.length) {
debug('Asking for snippets for ' + notifyHeaders.length + ' headers');
// POP3 downloads snippets as part of the sync process, there is no
// need to call downloadBodies.
if (account.accountDef.type === 'pop3+smtp') {
logic(scope, 'syncAccount_end');
inboxDone([newHeaders.length, notifyHeaders]);
} else if (this._universe.online) {
logic(scope, 'syncAccountSnippets_begin');
this._universe.downloadBodies(
newHeaders.slice(
0, syncbase.CRONSYNC_MAX_SNIPPETS_TO_FETCH_PER_ACCOUNT),
{
maximumBytesToFetch: syncbase.MAX_SNIPPET_BYTES
},
() => {
debug('Notifying for ' + newHeaders.length + ' headers');
logic(scope, 'syncAccountSnippets_end');
logic(scope, 'syncAccount_end');
inboxDone([newHeaders.length, notifyHeaders]);
});
} else {
logic(scope, 'syncAccount_end');
debug('UNIVERSE OFFLINE. Notifying for ' + newHeaders.length +
' headers');
inboxDone([newHeaders.length, notifyHeaders]);
}
} else {
logic(scope, 'syncAccount_end');
inboxDone();
}
// Kill the slice. This will release the connection and result in its
// death if we didn't schedule snippet downloads above.
slice.die();
});
// Pass true to force contacting the server.
storage.sliceOpenMostRecent(slice, true);
// Check the outbox; if it has pending messages, attempt to send them.
var outboxFolder = account.getFirstFolderWithType('outbox');
if (outboxFolder) {
var outboxStorage = account
.getFolderStorageForFolderId(outboxFolder.id);
if (outboxStorage.getKnownMessageCount() > 0) {
var outboxDone = latch.defer('outbox');
logic(scope, 'sendOutbox_begin');
this._universe.sendOutboxMessages(
account,
{
reason: 'syncAccount'
},
() => {
logic(scope, 'sendOutbox_end');
outboxDone();
});
}
}
// After both inbox and outbox syncing are algorithmically done, wait for
// any ongoing job operations to complete so that the app is not killed in
// the middle of a sync.
latch.then((latchResults) => {
// Right now, we ignore the outbox sync's results; we only care about
// the inbox.
var inboxResult = latchResults.inbox[0];
this._universe.waitForAccountOps(account, function() {
// Also wait for any account save to finish. Most likely failure will
// be new message headers not getting saved if the callback is not
// fired until after account saves.
account.runAfterSaves(function() {
resolve(inboxResult);
});
});
});
});
},
onAlarm: function(accountIds) {
logic(this, 'alarmFired', { accountIds: accountIds });
if (!accountIds) {
return;
}
var accounts = this._universe.accounts,
targetAccounts = [],
ids = [];
logic(this, 'cronSync_begin');
this._universe.__notifyStartedCronSync(accountIds);
// Make sure the acount IDs are still valid. This is to protect against an
// account deletion that did not clean up any alarms correctly.
accountIds.forEach(function(id) {
accounts.some(function(account) {
if (account.id === id) {
targetAccounts.push(account);
ids.push(id);
return true;
}
});
});
// Make sure next alarm is set up. In the case of a cold start background
// sync, this is a bit redundant in that the startup of the mailuniverse
// would trigger this work. However, if the app is already running, need to
// be sure next alarm is set up, so ensure the next sync is set up here. Do
// it here instead of after a sync in case an error in sync would prevent
// the next sync from getting scheduled.
this.ensureSync();
var syncResults = [];
var accountsResults = {
accountIds: accountIds
};
var done = () => {
// Make sure the ensure work is done before wrapping up.
this._ensureSyncPromise.then(() => {
if (syncResults.length) {
accountsResults.updates = syncResults;
}
this._universe.__notifyStoppedCronSync(accountsResults);
logic(this, 'syncAccounts_end', { accountsResults: accountsResults });
logic(this, 'cronSync_end');
});
};
// Nothing new to sync, probably old accounts. Just return and indicate that
// syncing is done.
if (!ids.length) {
done();
return;
}
logic(this, 'syncAccounts_begin');
Promise.all(targetAccounts.map((account) => {
return this.syncAccount(account).then((result) => {
if (result) {
syncResults.push({
id: account.id,
address: account.identities[0].address,
count: result[0],
latestMessageInfos: result[1]
});
}
});
}))
.then(done);
},
shutdown: function() {
router.unregister('cronsync');
}
};
}); // end define