Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Merge remote-tracking branch 'upstream/master' into bug814273-explode

Conflicts:
	README.md
  • Loading branch information...
commit e2db93b13ce6cf91e6474d161c9ab081d28f4ff8 2 parents 5ddb40b + 0d4848b
@asutherland asutherland authored
Showing with 1,896 additions and 371 deletions.
  1. +18 −11 README.md
  2. +44 −43 data/lib/imap.js
  3. +3 −7 data/lib/mailapi/accountcommon.js
  4. +7 −4 data/lib/mailapi/activesync/account.js
  5. +45 −15 data/lib/mailapi/activesync/folder.js
  6. +53 −29 data/lib/mailapi/activesync/jobs.js
  7. +0 −1  data/lib/mailapi/fake/account.js
  8. +7 −7 data/lib/mailapi/imap/folder.js
  9. +68 −58 data/lib/mailapi/imap/jobs.js
  10. +24 −15 data/lib/mailapi/imap/probe.js
  11. +73 −52 data/lib/mailapi/jobmixins.js
  12. +23 −4 data/lib/mailapi/mailslice.js
  13. +1 −1  data/lib/mailapi/mailuniverse.js
  14. +14 −5 data/lib/mailapi/searchfilter.js
  15. +113 −42 data/lib/mailapi/testhelper.js
  16. +2 −1  data/lib/node-tls.js
  17. +566 −68 test/activesync_server.js
  18. +26 −0 test/unit/test_activesync_general.js
  19. +639 −0 test/unit/test_activesync_mutation.js
  20. +9 −3 test/unit/test_compose.js
  21. +84 −4 test/unit/test_folder_storage.js
  22. +1 −1  test/unit/test_imap_just_auth.js
  23. +28 −0 test/unit/test_imap_mutation.js
  24. +28 −0 test/unit/test_imap_prober.js
  25. +18 −0 test/unit/test_search.js
  26. +2 −0  test/unit/xpcshell.ini
View
29 README.md
@@ -34,17 +34,8 @@ We have working IMAP and ActiveSync implementations. There are some current
limitations that we are working to resolve, such as message moves and
auto-configuration.
-Specific project progress tracking happens (a lot of which is tied up with the
-gaia email UI) in a few places:
-
-issues on this repo:
-https://github.com/mozilla-b2g/gaia-email-libs-and-more/issues?state=open
-
-"email" issues on the gaia project:
-https://github.com/mozilla-b2g/gaia/issues?labels=email&state=open
-
-This google doc spreadsheet that tries to break out the UX specs:
-https://docs.google.com/spreadsheet/ccc?key=0AsBxAH-jrP_GdERrWUxrd2lyZW5qMHprYl91VEdMNHc#gid=0
+All bug tracking happens on https://bugzilla.mozilla.org/ under the "Boot2Gecko"
+product and the "Gaia::E-Mail" component.
Find more links from the wiki page at:
https://wiki.mozilla.org/Gaia/Email
@@ -112,6 +103,22 @@ this stuff is still in here in various states of workingness, but is not a
priority or goal and a lot of it has now been removed. That which remains
is planned to be deleted or moved to a separate repository.
+## Submodules ##
+
+To make sure the submodules are initialized properly, please make sure to
+check out the repository recursively:
+
+```
+git clone --recursive https://github.com/mozilla-b2g/gaia-email-libs-and-more.git
+```
+
+If you already checked out without the --recursive flag, you can try the
+following command inside the repository directory:
+
+```
+git submodule update --init --recursive
+```
+
## Installing Into Gaia ##
Make sure you have a symlink, gaia-symlink, that points at the root directory
View
87 data/lib/imap.js
@@ -1,5 +1,6 @@
define(function(require, exports, module) {
var util = require('util'), $log = require('rdcommon/log'),
+ net = require('net'), tls = require('tls'),
EventEmitter = require('events').EventEmitter,
mailparser = require('mailparser/mailparser');
@@ -210,11 +211,6 @@ ImapConnection.prototype.hasCapability = function(name) {
ImapConnection.prototype.connect = function(loginCb) {
var self = this,
fnInit = function() {
- if (self._options.crypto === 'starttls') {
- self._send('STARTTLS', function() {
- self._state.conn.startTLS();
- });
- }
// First get pre-auth capabilities, including server-supported auth
// mechanisms
self._send('CAPABILITY', null, function() {
@@ -243,31 +239,36 @@ ImapConnection.prototype.connect = function(loginCb) {
loginCb = loginCb || emptyFn;
this._reset();
-
- var socketOptions = {
- binaryType: 'arraybuffer',
- useSSL: Boolean(this._options.crypto),
- };
- if (this._options.crypto === 'starttls')
- socketOptions.useSSL = 'starttls';
-
if (this._LOG) this._LOG.connect(this._options.host, this._options.port);
- this._state.conn = navigator.mozTCPSocket.open(
- this._options.host, this._options.port, socketOptions);
- // XXX rely on mozTCPSocket for this?
+ this._state.conn = (this._options.crypto ? tls : net).connect(
+ this._options.port, this._options.host);
this._state.tmrConn = setTimeoutFunc(this._fnTmrConn.bind(this, loginCb),
this._options.connTimeout);
- this._state.conn.onopen = function(evt) {
+ this._state.conn.on('connect', function() {
if (self._LOG) self._LOG.connected();
clearTimeoutFunc(self._state.tmrConn);
self._state.status = STATES.NOAUTH;
+ /*
+ We will need to add support for node-like starttls emulation on top of TCPSocket
+ once TCPSocket supports starttls (see also bug 784816).
+
+ if (self._options.crypto === 'starttls') {
+ self._send('STARTTLS', function() {
+ starttls(self, function() {
+ if (!self.authorized)
+ throw new Error("starttls failed");
+ fnInit();
+ });
+ });
+ return;
+ }
+ */
fnInit();
- };
- this._state.conn.ondata = function(evt) {
+ });
+ this._state.conn.on('data', function(buffer) {
try {
- var buffer = Buffer(evt.data);
processData(buffer);
}
catch (ex) {
@@ -276,7 +277,7 @@ ImapConnection.prototype.connect = function(loginCb) {
console.error('Stack:', ex.stack);
throw ex;
}
- };
+ });
/**
* Process up to one thing. Generally:
* - If we are processing a literal, we make sure we have the data for the
@@ -440,7 +441,7 @@ ImapConnection.prototype.connect = function(loginCb) {
} else if (data[1] === 'NO' || data[1] === 'BAD' || data[1] === 'BYE') {
if (self._LOG && data[1] === 'BAD')
self._LOG.bad(data[2]);
- self._state.conn.close();
+ self._state.conn.end();
return;
}
if (!self._state.isReady)
@@ -561,8 +562,9 @@ ImapConnection.prototype.connect = function(loginCb) {
box.parent = parent;
}
box.displayName = decodeModifiedUtf7(name);
- if (!curChildren[name])
- curChildren[name] = box;
+ if (curChildren[name])
+ box.children = curChildren[name].children;
+ curChildren[name] = box;
}
break;
// QRESYNC (when successful) generates a "VANISHED (EARLIER) uids"
@@ -742,14 +744,14 @@ ImapConnection.prototype.connect = function(loginCb) {
}
};
- this._state.conn.onclose = function onClose() {
+ this._state.conn.on('close', function onClose() {
self._reset();
if (this._LOG) this._LOG.closed();
self.emit('close');
- };
- this._state.conn.onerror = function(evt) {
+ });
+ this._state.conn.on('error', function(err) {
try {
- var err = evt.data, errType;
+ var errType;
// (only do error probing on things we can safely use 'in' on)
if (err && typeof(err) === 'object') {
// detect an nsISSLStatus instance by an unusual property.
@@ -772,7 +774,7 @@ ImapConnection.prototype.connect = function(loginCb) {
console.error("Error in imap onerror:", ex);
throw ex;
}
- };
+ });
};
/**
@@ -783,9 +785,8 @@ ImapConnection.prototype.die = function() {
// NB: there's still a lot of events that could happen, but this is only
// being used by unit tests right now.
if (this._state.conn) {
- this._state.conn.onclose = null;
- this._state.conn.onerror = null;
- this._state.conn.close();
+ this._state.conn.removeAllListeners();
+ this._state.conn.end();
}
this._reset();
this._LOG.__die();
@@ -994,8 +995,8 @@ ImapConnection.prototype.append = function(data, options, cb) {
self._state.conn.send(Buffer(data + CRLF));
}
else {
- self._state.conn.send(data);
- self._state.conn.send(CRLF_BUFFER);
+ self._state.conn.write(data);
+ self._state.conn.write(CRLF_BUFFER);
}
if (this._LOG) this._LOG.sendData(data.length, data);
});
@@ -1029,7 +1030,7 @@ ImapConnection.prototype.multiappend = function(messages, cb) {
if (err || done)
return cb(err, iNextMessage - 1);
- self._state.conn.send(typeof(data) === 'string' ? Buffer(data) : data);
+ self._state.conn.write(typeof(data) === 'string' ? Buffer(data) : data);
// The message literal itself should end with a newline. We don't want to
// send an extra one because then that terminates the command.
if (self._LOG) self._LOG.sendData(data.length, data);
@@ -1040,12 +1041,12 @@ ImapConnection.prototype.multiappend = function(messages, cb) {
data = message.messageText;
buildAppendClause(message);
cmd += CRLF;
- self._state.conn.send(Buffer(cmd));
+ self._state.conn.write(Buffer(cmd));
if (self._LOG) self._LOG.sendData(cmd.length, cmd);
}
else {
// This terminates the command.
- self._state.conn.send(CRLF_BUFFER);
+ self._state.conn.write(CRLF_BUFFER);
if (self._LOG) self._LOG.sendData(2, CRLF);
done = true;
}
@@ -1221,7 +1222,7 @@ ImapConnection.prototype._fnTmrConn = function(loginCb) {
var err = new Error('Connection timed out');
err.type = 'timeout';
loginCb(err);
- this._state.conn.close();
+ this._state.conn.end();
};
ImapConnection.prototype._store = function(which, uids, flags, isAdding, cb) {
@@ -1431,12 +1432,12 @@ ImapConnection.prototype._send = function(cmdstr, cmddata, cb, dispatchFunc,
}
// does not fit in buffer, just do separate writes...
else {
- this._state.conn.send(gSendBuf.subarray(0, iWrite));
+ this._state.conn.write(gSendBuf.subarray(0, iWrite));
if (typeof(data) === 'string')
- this._state.conn.send(Buffer(data));
+ this._state.conn.write(Buffer(data));
else
- this._state.conn.send(data);
- this._state.conn.send(CRLF_BUFFER);
+ this._state.conn.write(data);
+ this._state.conn.write(CRLF_BUFFER);
// set to zero to tell ourselves we don't need to send...
iWrite = 0;
}
@@ -1444,7 +1445,7 @@ ImapConnection.prototype._send = function(cmdstr, cmddata, cb, dispatchFunc,
if (iWrite) {
gSendBuf[iWrite++] = 13;
gSendBuf[iWrite++] = 10;
- this._state.conn.send(gSendBuf.subarray(0, iWrite));
+ this._state.conn.write(gSendBuf.subarray(0, iWrite));
}
if (this._LOG) { if (!bypass) this._LOG.cmd_begin(prefix, cmd, /^LOGIN$/.test(cmd) ? '***BLEEPING OUT LOGON***' : data); else this._LOG.bypassCmd(prefix, cmd);}
View
10 data/lib/mailapi/accountcommon.js
@@ -41,10 +41,6 @@ const PIECE_ACCOUNT_TYPE_TO_CLASS = {
//'gmail-imap': GmailAccount,
};
-// A boring signature that conveys the person was probably typing on a touch
-// screen, helping to explain typos and short replies.
-const DEFAULT_SIGNATURE = exports.DEFAULT_SIGNATURE =
- 'Sent from my Firefox OS device.';
// The number of milliseconds to wait for various (non-ActiveSync) XHRs to
// complete during the autoconfiguration process. This value is intentionally
@@ -461,7 +457,7 @@ Configurators['imap+smtp'] = {
name: userDetails.displayName,
address: userDetails.emailAddress,
replyTo: null,
- signature: DEFAULT_SIGNATURE
+ signature: null
},
],
tzOffset: tzOffset,
@@ -524,7 +520,7 @@ Configurators['fake'] = {
name: userDetails.displayName,
address: userDetails.emailAddress,
replyTo: null,
- signature: DEFAULT_SIGNATURE
+ signature: null
},
]
};
@@ -646,7 +642,7 @@ Configurators['activesync'] = {
name: userDetails.displayName || domainInfo.displayName,
address: userDetails.emailAddress,
replyTo: null,
- signature: DEFAULT_SIGNATURE
+ signature: null
},
]
};
View
11 data/lib/mailapi/activesync/account.js
@@ -333,10 +333,6 @@ ActiveSyncAccount.prototype = {
existingInboxMeta.name = displayName;
existingInboxMeta.path = path;
existingInboxMeta.depth = depth;
- // Its folder connection needs to know the updated server id since it
- // copied it out.
- let folderStorage = this._folderStorages[existingInboxMeta.id];
- folderStorage.folderSyncer.folderConn.serverId = serverId;
return existingInboxMeta;
}
}
@@ -353,6 +349,7 @@ ActiveSyncAccount.prototype = {
lastSyncedAt: 0,
syncKey: '0',
},
+ // any changes to the structure here must be reflected in _recreateFolder!
$impl: {
nextId: 0,
nextHeaderBlock: 0,
@@ -418,9 +415,15 @@ ActiveSyncAccount.prototype = {
_recreateFolder: function asa__recreateFolder(folderId, callback) {
this._LOG.recreateFolder(folderId);
let folderInfo = this._folderInfos[folderId];
+ folderInfo.$impl = {
+ nextId: 0,
+ nextHeaderBlock: 0,
+ nextBodyBlock: 0,
+ };
folderInfo.accuracy = [];
folderInfo.headerBlocks = [];
folderInfo.bodyBlocks = [];
+ folderInfo.serverIdHeaderBlockMapping = {};
if (this._deadFolderIds === null)
this._deadFolderIds = [];
View
60 data/lib/mailapi/activesync/folder.js
@@ -75,7 +75,6 @@ function ActiveSyncFolderConn(account, storage, _parentLog) {
this._LOG = LOGFAB.ActiveSyncFolderConn(this, _parentLog, storage.folderId);
this.folderMeta = storage.folderMeta;
- this.serverId = this.folderMeta.serverId;
if (!this.syncKey)
this.syncKey = '0';
@@ -89,6 +88,10 @@ ActiveSyncFolderConn.prototype = {
return this.folderMeta.syncKey = value;
},
+ get serverId() {
+ return this.folderMeta.serverId;
+ },
+
/**
* Get the filter type for this folder. The account-level syncRange property
* takes precedence here, but if it's set to "auto", we'll look at the
@@ -214,7 +217,7 @@ ActiveSyncFolderConn.prototype = {
});
e.addEventListener(base.concat(ie.Collection, ie.Estimate),
function(node) {
- estimate = parseInt(node.children[0].textContent);
+ estimate = parseInt(node.children[0].textContent, 10);
});
try {
@@ -711,7 +714,7 @@ ActiveSyncFolderConn.prototype = {
break;
case asb.EstimatedDataSize:
case em.AttSize:
- attachment.sizeEstimate = parseInt(attachDataText);
+ attachment.sizeEstimate = parseInt(attachDataText, 10);
break;
case asb.ContentId:
attachment.contentId = attachDataText;
@@ -755,16 +758,22 @@ ActiveSyncFolderConn.prototype = {
syncDateRange: function asfc_syncDateRange(startTS, endTS, accuracyStamp,
doneCallback, progressCallback) {
- let storage = this._storage;
- let folderConn = this;
- let messagesSeen = 0;
+ let folderConn = this,
+ addedMessages = 0,
+ changedMessages = 0,
+ deletedMessages = 0;
this._LOG.syncDateRange_begin(null, null, null, startTS, endTS);
this._enumerateFolderChanges(function (error, added, changed, deleted,
moreAvailable) {
+ let storage = folderConn._storage;
+
if (error === 'badkey') {
folderConn._account._recreateFolder(storage.folderId, function(s) {
- folderConn.storage = s;
+ // If we got a bad sync key, we'll end up creating a new connection,
+ // so just clear out the old storage to make this connection unusable.
+ folderConn._storage = null;
+ folderConn._LOG.syncDateRange_end(null, null, null, startTS, endTS);
});
return;
}
@@ -774,28 +783,49 @@ ActiveSyncFolderConn.prototype = {
}
for (let [,message] in Iterator(added)) {
+ // If we already have this message, it's probably because we moved it as
+ // part of a local op, so let's assume that the data we already have is
+ // ok. XXX: We might want to verify this, to be safe.
+ if (storage.hasMessageWithServerId(message.header.srvid))
+ continue;
+
storage.addMessageHeader(message.header);
storage.addMessageBody(message.header, message.body);
+ addedMessages++;
}
for (let [,message] in Iterator(changed)) {
+ // If we don't know about this message, just bail out.
+ if (!storage.hasMessageWithServerId(message.header.srvid))
+ continue;
+
storage.updateMessageHeaderByServerId(message.header.srvid, true,
function(oldHeader) {
message.header.mergeInto(oldHeader);
return true;
});
+ changedMessages++;
// XXX: update bodies
}
for (let [,messageGuid] in Iterator(deleted)) {
+ // If we don't know about this message, it's probably because we already
+ // deleted it.
+ if (!storage.hasMessageWithServerId(messageGuid))
+ continue;
+
storage.deleteMessageByServerId(messageGuid);
+ deletedMessages++;
}
- messagesSeen += added.length + changed.length + deleted.length;
-
if (!moreAvailable) {
- folderConn._LOG.syncDateRange_end(added.length, changed.length,
- deleted.length, startTS, endTS);
+ let messagesSeen = addedMessages + changedMessages + deletedMessages;
+
+ // Note: For the second argument here, we report the number of messages
+ // we saw that *changed*. This differs from IMAP, which reports the
+ // number of messages it *saw*.
+ folderConn._LOG.syncDateRange_end(addedMessages, changedMessages,
+ deletedMessages, startTS, endTS);
storage.markSyncRange(startTS, endTS, 'XXX', accuracyStamp);
doneCallback(null, null, messagesSeen);
}
@@ -828,7 +858,9 @@ ActiveSyncFolderConn.prototype = {
w.tag(as.SyncKey, this.syncKey)
.tag(as.CollectionId, this.serverId)
- // DeletesAsMoves defaults to true, so we can omit it
+ // Use DeletesAsMoves in non-trash folders. Don't use it in trash
+ // folders because that doesn't make any sense.
+ .tag(as.DeletesAsMoves, this.folderMeta.type === 'trash' ? '0' : '1')
// GetChanges defaults to true, so we must explicitly disable it to
// avoid hearing about changes.
.tag(as.GetChanges, '0')
@@ -867,13 +899,11 @@ ActiveSyncFolderConn.prototype = {
status = node.children[0].textContent;
});
- //console.warn('COMMAND RESULT:\n', aResponse.dump());
- //aResponse.rewind();
try {
e.run(aResponse);
}
catch (ex) {
- console.error('Error parsing Sync reponse:', ex, '\n', ex.stack);
+ console.error('Error parsing Sync response:', ex, '\n', ex.stack);
callWhenDone('unknown');
return;
}
View
82 data/lib/mailapi/activesync/jobs.js
@@ -121,17 +121,21 @@ ActiveSyncJobDriver.prototype = {
if (--modsToGo === 0)
callWhenDone();
}
+
+ // Filter out any offline headers, since the server naturally can't do
+ // anything for them. If this means we have no headers at all, just bail
+ // out.
+ serverIds = serverIds.filter(function(srvid) { return !!srvid; });
+ if (!serverIds.length) {
+ callWhenDone();
+ return;
+ }
+
folderConn.performMutation(
function withWriter(w) {
for (let i = 0; i < serverIds.length; i++) {
- let srvid = serverIds[i];
- // If the header is somehow an offline header, it will be null and
- // there is nothing we can really do for it.
- if (!srvid)
- continue;
-
w.stag(as.Change)
- .tag(as.ServerId, srvid)
+ .tag(as.ServerId, serverIds[i])
.stag(as.ApplicationData);
if (markRead !== undefined)
@@ -194,27 +198,37 @@ ActiveSyncJobDriver.prototype = {
let aggrErr = null, account = this.account,
targetFolderStorage = this.account.getFolderStorageForFolderId(
op.targetFolder);
- const as = $ascp.AirSync.Tags;
- const em = $ascp.Email.Tags;
const mo = $ascp.Move.Tags;
this._partitionAndAccessFoldersSequentially(
op.messages, true,
function perFolder(folderConn, storage, serverIds, namers, callWhenDone) {
+ // Filter out any offline headers, since the server naturally can't do
+ // anything for them. If this means we have no headers at all, just bail
+ // out.
+ serverIds = serverIds.filter(function(srvid) { return !!srvid; });
+ if (!serverIds.length) {
+ callWhenDone();
+ return;
+ }
+
+ // Filter out any offline headers, since the server naturally can't do
+ // anything for them. If this means we have no headers at all, just bail
+ // out.
+ serverIds = serverIds.filter(function(srvid) { return !!srvid; });
+ if (!serverIds.length) {
+ callWhenDone();
+ return;
+ }
+
let w = new $wbxml.Writer('1.3', 1, 'UTF-8');
w.stag(mo.MoveItems);
-
for (let i = 0; i < serverIds.length; i++) {
- let srvid = serverIds[i];
- // If the header is somehow an offline header, it will be null and
- // there is nothing we can really do for it.
- if (!srvid)
- continue;
w.stag(mo.Move)
- .tag(mo.SrcMsgId, srvid)
- .tag(mo.SrcFldId, storage.folderMeta.serverId)
- .tag(mo.DstFldId, targetFolderStorage.folderMeta.serverId)
- .etag(mo.Move);
+ .tag(mo.SrcMsgId, serverIds[i])
+ .tag(mo.SrcFldId, storage.folderMeta.serverId)
+ .tag(mo.DstFldId, targetFolderStorage.folderMeta.serverId)
+ .etag(mo.Move);
}
w.etag(mo.MoveItems);
@@ -258,18 +272,21 @@ ActiveSyncJobDriver.prototype = {
this._partitionAndAccessFoldersSequentially(
op.messages, true,
function perFolder(folderConn, storage, serverIds, namers, callWhenDone) {
+ // Filter out any offline headers, since the server naturally can't do
+ // anything for them. If this means we have no headers at all, just bail
+ // out.
+ serverIds = serverIds.filter(function(srvid) { return !!srvid; });
+ if (!serverIds.length) {
+ callWhenDone();
+ return;
+ }
+
folderConn.performMutation(
function withWriter(w) {
for (let i = 0; i < serverIds.length; i++) {
- let srvid = serverIds[i];
- // If the header is somehow an offline header, it will be null and
- // there is nothing we can really do for it.
- if (!srvid)
- continue;
-
w.stag(as.Delete)
- .tag(as.ServerId, srvid)
- .etag(as.Delete);
+ .tag(as.ServerId, serverIds[i])
+ .etag(as.Delete);
}
},
function mutationPerformed(err) {
@@ -340,8 +357,15 @@ ActiveSyncJobDriver.prototype = {
doneCallback(err ? 'aborted-retry' : null, null, !err);
if (inboxStorage && inboxStorage.hasActiveSlices) {
- console.log("Refreshing fake inbox");
- inboxStorage.resetAndRefreshActiveSlices();
+ if (!err) {
+ console.log("Refreshing fake inbox");
+ inboxStorage.resetAndRefreshActiveSlices();
+ }
+ // XXX: If we do have an error here, we should probably report
+ // syncfailed on the slices to let the user retry. However, what needs
+ // retrying is syncFolderList, not syncing the messages in a folder.
+ // Since that's complicated to handle, and syncFolderList will retry
+ // automatically, we can ignore that case for now.
}
});
},
View
1  data/lib/mailapi/fake/account.js
@@ -675,7 +675,6 @@ function FakeAccount(universe, accountDef, folderInfo, receiveProtoConn, _LOG) {
this.meta = folderInfo.$meta;
this.mutations = folderInfo.$mutations;
- this.deferredMutations = folderInfo.$deferredMutations;
}
exports.FakeAccount = FakeAccount;
FakeAccount.prototype = {
View
14 data/lib/mailapi/imap/folder.js
@@ -145,13 +145,6 @@ function ImapFolderConn(account, storage, _parentLog) {
}
ImapFolderConn.prototype = {
/**
- * Can we grow this sync range? IMAP always lets us do this.
- */
- get canGrowSync() {
- return true;
- },
-
- /**
* Acquire a connection and invoke the callback once we have it and we have
* entered the folder. This method should only be called when running
* inside `runMutexed`.
@@ -831,6 +824,13 @@ ImapFolderSyncer.prototype = {
*/
syncable: true,
+ /**
+ * Can we grow this sync range? IMAP always lets us do this.
+ */
+ get canGrowSync() {
+ return true;
+ },
+
syncDateRange: function(startTS, endTS, syncCallback, doneCallback,
progressCallback) {
syncCallback('sync', false);
View
126 data/lib/mailapi/imap/jobs.js
@@ -522,20 +522,22 @@ ImapJobDriver.prototype = {
var state = this._state, stateDelta = this._stateDelta, aggrErr = null;
if (!stateDelta.serverIdMap)
stateDelta.serverIdMap = {};
- // resolve the target folder again
- this._accessFolderForMutation(
- targetFolderId || op.targetFolder, true,
- function gotTargetConn(targetConn, targetStorage) {
- var uidnext = targetConn.box._uidnext;
-
- this._partitionAndAccessFoldersSequentially(
- op.messages, true,
- function perFolder(folderConn, sourceStorage, serverIds, namers,
- perFolderDone){
- // - copies are done, find the UIDs
- // XXX process UIDPLUS output when present, avoiding this step.
- var guidToNamer = {}, waitingOnHeaders = namers.length,
- reportedHeaders = 0, retriesLeft = 3;
+ 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 = {}, waitingOnHeaders = namers.length,
+ reportedHeaders = 0, retriesLeft = 3, targetConn;
+
+ // - got the target folder conn, now do the copies
+ function gotTargetConn(targetConn, targetStorage) {
+ var uidnext = targetConn.box._uidnext;
+ folderConn._conn.copy(serverIds, targetStorage.folderMeta.path,
+ copiedMessages_reselect);
function copiedMessages_reselect() {
// Force a re-select of the folder to try and force the server to
@@ -549,6 +551,7 @@ ImapJobDriver.prototype = {
// selection and lose.
targetConn.reselectBox(copiedMessages_findNewUIDs);
}
+ // - copies are done, find the UIDs
function copiedMessages_findNewUIDs() {
var fetcher = targetConn._conn.fetch(
uidnext + ':*',
@@ -612,54 +615,61 @@ ImapJobDriver.prototype = {
return true;
});
}
- function foundUIDs_deleteOriginals() {
- folderConn._conn.addFlags(serverIds, ['\\Deleted'],
- deletedMessages);
- }
- function deletedMessages(err) {
- if (err)
- aggrErr = true;
- perFolderDone();
+ }
+
+ function foundUIDs_deleteOriginals() {
+ folderConn._conn.addFlags(serverIds, ['\\Deleted'],
+ 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;
+ }
- // 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;
+ if (sourceStorage.folderId === targetFolderId) {
+ if (op.type === 'move') {
+ // A move from a folder to itself is a no-op.
+ processNext();
}
- // 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;
+ 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();
}
-
- folderConn._conn.copy(
- serverIds,
- targetStorage.folderMeta.path,
- copiedMessages_reselect);
- },
- function() {
- jobDoneCallback(aggrErr);
- },
- null,
- false,
- 'local move source');
- // get a connection in the source folder, uid validity is asserted
- // issue the (potentially bulk) copy
- // wait for copy success
- // mark the source messages deleted
- }.bind(this),
- function targetFolderDead() {
- },
- 'move target');
+ }
+ else {
+ // Resolve the target folder again.
+ this._accessFolderForMutation(targetFolderId, true, gotTargetConn,
+ function targetFolderDead() {},
+ 'move target');
+ }
+ }.bind(this),
+ function() {
+ jobDoneCallback(aggrErr);
+ },
+ null,
+ false,
+ 'local move source');
},
/**
View
39 data/lib/mailapi/imap/probe.js
@@ -217,6 +217,26 @@ var normalizeError = exports.normalizeError = function normalizeError(err) {
*/
const DEFAULT_TZ_OFFSET = -7 * 60 * 60 * 1000;
+var extractTZFromHeaders = exports._extractTZFromHeaders =
+ function extractTZFromHeaders(allHeaders) {
+ for (var i = 0; i < allHeaders.length; i++) {
+ var hpair = allHeaders[i];
+ if (hpair.key !== 'received')
+ continue;
+ var tzMatch = /([+-]\d{4})/.exec(hpair.value);
+ if (tzMatch) {
+ var tz =
+ parseInt(tzMatch[1].substring(1, 3), 10) * 60 * 60 * 1000 +
+ parseInt(tzMatch[1].substring(3, 5), 10) * 60 * 1000;
+ if (tzMatch[1].substring(0, 1) === '-')
+ tz *= -1;
+ return tz;
+ }
+ }
+
+ return null;
+};
+
/**
* Try and infer the current effective timezone of the server by grabbing the
* most recent message as implied by UID (may be inaccurate), and then looking
@@ -265,21 +285,10 @@ var getTZOffset = exports.getTZOffset = function getTZOffset(conn, callback) {
});
fetcher.on('message', function onMsg(msg) {
msg.on('end', function onMsgEnd() {
- var allHeaders = msg.msg.headers;
- for (var i = 0; i < allHeaders.length; i++) {
- var hpair = allHeaders[i];
- if (hpair.key !== 'received')
- continue;
- var tzMatch = /([+-]\d{4})/.exec(hpair.value);
- if (tzMatch) {
- var tz =
- parseInt(tzMatch[1].substring(1, 3)) * 60 * 60 * 1000+
- parseInt(tzMatch[1].substring(3, 5)) * 60 * 1000;
- if (tzMatch[1].substring(0, 1) === '-')
- tz *= -1;
- callback(null, tz);
- return;
- }
+ var tz = extractTZFromHeaders(msg.msg.headers);
+ if (tz !== null) {
+ callback(null, tz);
+ return;
}
// If we are here, the message somehow did not have a Received
// header. Try again with another known UID or fail out if we
View
125 data/lib/mailapi/jobmixins.js
@@ -73,69 +73,90 @@ exports.local_do_move = function(op, doneCallback, targetFolderId) {
op.guids = {};
const nukeServerIds = !this.resilientServerIds;
- var stateDelta = this._stateDelta, addWait = 0;
+ var stateDelta = this._stateDelta, addWait = 0, self = this;
if (!stateDelta.moveMap)
stateDelta.moveMap = {};
if (!stateDelta.serverIdMap)
stateDelta.serverIdMap = {};
- var perSourceFolder = function perSourceFolder(ignoredConn, targetStorage) {
- this._partitionAndAccessFoldersSequentially(
- op.messages, false,
- function perFolder(ignoredConn, sourceStorage, headers, namers,
- perFolderDone) {
- // -- get the body for the next header (or be done)
- function processNext() {
- if (iNextHeader >= headers.length) {
- perFolderDone();
- return;
+ if (!targetFolderId)
+ targetFolderId = op.targetFolder;
+
+ this._partitionAndAccessFoldersSequentially(
+ op.messages, false,
+ function perFolder(ignoredConn, sourceStorage, headers, namers,
+ perFolderDone) {
+ // -- get the body for the next header (or be done)
+ function processNext() {
+ if (iNextHeader >= headers.length) {
+ perFolderDone();
+ return;
+ }
+ header = headers[iNextHeader++];
+ sourceStorage.getMessageBody(header.suid, header.date,
+ gotBody_nowDelete);
+ }
+ // -- delete the header and body from the source
+ function gotBody_nowDelete(_body) {
+ body = _body;
+
+ // We need an entry in the server id map if we are moving/deleting it.
+ // We don't need this if we're moving a message to the folder it's
+ // already in, but it doesn't hurt anything.
+ if (header.srvid)
+ stateDelta.serverIdMap[header.suid] = header.srvid;
+
+ if (sourceStorage.folderId === targetFolderId) {
+ if (op.type === 'move') {
+ // A move from a folder to itself is a no-op.
+ processNext();
+ }
+ 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.
+ sourceStorage.deleteMessageHeaderAndBody(header, processNext);
}
- header = headers[iNextHeader++];
- sourceStorage.getMessageBody(header.suid, header.date,
- gotBody_nowDelete);
}
- // -- delete the header and body from the source
- function gotBody_nowDelete(_body) {
- body = _body;
- sourceStorage.deleteMessageHeaderAndBody(header, deleted_nowAdd);
+ else {
+ sourceStorage.deleteMessageHeaderAndBody(
+ header, deleted_nowOpenTarget);
}
- // -- add the header/body to the target folder
- function deleted_nowAdd() {
- var sourceSuid = header.suid;
-
- // We need an entry in the server id map if we are moving it.
- if (header.srvid)
- stateDelta.serverIdMap[sourceSuid] = header.srvid;
+ }
+ // -- open the target folder
+ function deleted_nowOpenTarget() {
+ self._accessFolderForMutation(targetFolderId, false,
+ targetOpened_nowAdd, null,
+ 'local move target');
+ }
+ // -- add the header/body to the target folder
+ function targetOpened_nowAdd(ignoredConn, targetStorage) {
+ var sourceSuid = header.suid;
- // - update id fields
- header.id = targetStorage._issueNewHeaderId();
- header.suid = targetStorage.folderId + '/' + header.id;
- if (nukeServerIds)
- header.srvid = null;
+ // - update id fields
+ header.id = targetStorage._issueNewHeaderId();
+ header.suid = targetStorage.folderId + '/' + header.id;
+ if (nukeServerIds)
+ header.srvid = null;
- stateDelta.moveMap[sourceSuid] = header.suid;
+ stateDelta.moveMap[sourceSuid] = header.suid;
- addWait = 2;
- targetStorage.addMessageHeader(header, added);
- targetStorage.addMessageBody(header, body, added);
- }
- function added() {
- if (--addWait !== 0)
- return;
- processNext();
- }
- var iNextHeader = 0, header = null, body = null, addWait = 0;
+ addWait = 2;
+ targetStorage.addMessageHeader(header, added);
+ targetStorage.addMessageBody(header, body, added);
+ }
+ function added() {
+ if (--addWait !== 0)
+ return;
processNext();
- },
- function() {
- doneCallback(null, null, true);
- },
- null,
- false,
- 'local move source');
- }.bind(this);
- this._accessFolderForMutation(
- targetFolderId || op.targetFolder, false,
- perSourceFolder, null, 'local move target');
+ }
+ var iNextHeader = 0, header = null, body = null, addWait = 0;
+ processNext();
+ },
+ function() {
+ doneCallback(null, null, true);
+ },
+ null,
+ false,
+ 'local move source');
};
// XXX implement!
View
27 data/lib/mailapi/mailslice.js
@@ -2235,12 +2235,18 @@ FolderStorage.prototype = {
var progressCallback = slice.setSyncProgress.bind(slice);
- // If we're offline or the folder can't be synchronized right now, then
- // there's nothing to look into; use the DB.
- if (!this._account.universe.online ||
- !this.folderSyncer.syncable) {
+ // If we're offline, then there's nothing to look into; use the DB.
+ if (!this._account.universe.online) {
existingDataGood = true;
}
+ // If the folder can't be synchronized right now, just report the sync as
+ // blocked. We'll update it soon enough.
+ else if (!this.folderSyncer.syncable) {
+ console.log('Synchronization is currently blocked; waiting...');
+ slice.setStatus('syncblocked', false, true, false, 0.0);
+ releaseMutex();
+ return;
+ }
else if (this._accuracyRanges.length && !forceDeepening) {
ainfo = this._accuracyRanges[0];
var newestMessage = this.getYoungestMessageTimestamp();
@@ -3334,6 +3340,19 @@ FolderStorage.prototype = {
this._curSyncSlice.onHeaderAdded(header, true, false);
},
+ hasMessageWithServerId: function(srvid) {
+ if (!this._serverIdHeaderBlockMapping)
+ throw new Error('Server ID mapping not supported for this storage!');
+
+ var blockId = this._serverIdHeaderBlockMapping[srvid];
+ if (srvid === undefined) {
+ this._LOG.serverIdMappingMissing(srvid);
+ return false;
+ }
+
+ return !!blockId;
+ },
+
deleteMessageHeaderAndBody: function(header, callback) {
if (this._pendingLoads.length) {
this._deferredCalls.push(this.deleteMessageHeaderAndBody.bind(
View
2  data/lib/mailapi/mailuniverse.js
@@ -978,7 +978,7 @@ MailUniverse.prototype = {
* and transferred across to the non-deferred queue at account-load time.
*/
_deferOp: function(account, op) {
- account.deferredMutations.push(op.longtermId);
+ this._opsByAccount[account.id].deferred.push(op.longtermId);
if (this._deferredOpTimeout !== null)
this._deferredOpTimeout = window.setTimeout(
this._boundQueueDeferredOps, $syncbase.DEFERRED_OP_DELAY_MS);
View
19 data/lib/mailapi/searchfilter.js
@@ -287,8 +287,12 @@ exports.SubjectFilter = SubjectFilter;
SubjectFilter.prototype = {
needsBody: false,
testMessage: function(header, body, match) {
+ const subject = header.subject;
+ // Empty subjects can't match *anything*; no empty regexes allowed, etc.
+ if (!subject)
+ return false;
const phrase = this.phrase,
- subject = header.subject, slen = subject.length,
+ slen = subject.length,
stopAfter = this.stopAfter,
contextBefore = this.contextBefore, contextAfter = this.contextAfter,
matches = [];
@@ -481,10 +485,15 @@ MessageFilterer.prototype = {
// header.subject, 'body?', !!body, ')');
var matched = false, matchObj = {};
const filters = this.filters;
- for (var i = 0; i < filters.length; i++) {
- var filter = filters[i];
- if (filter.testMessage(header, body, matchObj))
- matched = true;
+ try {
+ for (var i = 0; i < filters.length; i++) {
+ var filter = filters[i];
+ if (filter.testMessage(header, body, matchObj))
+ matched = true;
+ }
+ }
+ catch (ex) {
+ console.error('filter exception', ex, '\n', ex.stack);
}
//console.log(' =>', matched, JSON.stringify(matchObj));
if (matched)
View
155 data/lib/mailapi/testhelper.js
@@ -463,6 +463,22 @@ var TestCommonAccountMixins = {
});
},
+ do_refreshFolderView: function(viewThing, expectedValues, checkExpected,
+ expectedFlags) {
+ var self = this;
+ this.T.action(this, 'refreshes', viewThing, function() {
+ var totalExpected = self._expect_dateSyncs(viewThing.testFolder,
+ expectedValues);
+ self.expect_messagesReported(totalExpected);
+ self.expect_headerChanges(viewThing, checkExpected, expectedFlags);
+
+ self._expect_storage_mutexed(viewThing.testFolder.storageActor,
+ 'refresh');
+
+ viewThing.slice.refresh();
+ });
+ },
+
/**
* Expect that a mutex operation will be run on the provided storageActor of
* the given type. Ignore block load and deletion notifications during this
@@ -557,6 +573,27 @@ var TestCommonAccountMixins = {
testStep.timeoutMS = 5000;
return testStep;
},
+
+ /**
+ * Locally delete the message like we heard it was deleted on the server; but
+ * we won't have actually heard it from the server. We do this outside a
+ * mutex because we're a unit test hack and nothing should be going on.
+ */
+ fakeServerMessageDeletion: function(mailHeader) {
+ var self = this;
+ this.RT.reportActiveActorThisStep(this);
+
+ var folderStorage =
+ this.universe.getFolderStorageForMessageSuid(mailHeader.id);
+ this.expect_deletionNotified(1);
+ folderStorage.getMessageHeader(
+ mailHeader.id, mailHeader.date,
+ function(header) {
+ folderStorage.deleteMessageHeaderAndBody(header, function() {
+ self._logger.deletionNotified(1);
+ });
+ });
+ },
};
var TestImapAccountMixins = {
@@ -697,7 +734,7 @@ var TestImapAccountMixins = {
self._logger.accountCreated();
});
});
- }).timeoutMS = 5000; // there can be slow startups...
+ }).timeoutMS = 10000; // there can be slow startups...
},
/**
@@ -956,27 +993,6 @@ var TestImapAccountMixins = {
});
},
- /**
- * Locally delete the message like we heard it was deleted on the server; but
- * we won't have actually heard it from the server. We do this outside a
- * mutex because we're a unit test hack and nothing should be going on.
- */
- fakeServerMessageDeletion: function(mailHeader) {
- var self = this;
- this.RT.reportActiveActorThisStep(this);
-
- var folderStorage =
- this.universe.getFolderStorageForMessageSuid(mailHeader.id);
- this.expect_deletionNotified(1);
- folderStorage.getMessageHeader(
- mailHeader.id, mailHeader.date,
- function(header) {
- folderStorage.deleteMessageHeaderAndBody(header, function() {
- self._logger.deletionNotified(1);
- });
- });
- },
-
_expect_dateSyncs: function(testFolder, expectedValues, extraFlags) {
this.RT.reportActiveActorThisStep(this.eImapAccount);
this.RT.reportActiveActorThisStep(testFolder.connActor);
@@ -1086,22 +1102,6 @@ var TestImapAccountMixins = {
return testStep;
},
- do_refreshFolderView: function(viewThing, expectedValues, checkExpected,
- expectedFlags) {
- var self = this;
- this.T.action(this, 'refreshes', viewThing, function() {
- var totalExpected = self._expect_dateSyncs(viewThing.testFolder,
- expectedValues);
- self.expect_messagesReported(totalExpected);
- self.expect_headerChanges(viewThing, checkExpected, expectedFlags);
-
- self._expect_storage_mutexed(viewThing.testFolder.storageActor,
- 'refresh');
-
- viewThing.slice.refresh();
- });
- },
-
do_growFolderView: function(viewThing, dirMagnitude, userRequestsGrowth,
alreadyExists, expectedValues, expectedFlags,
extraFlags) {
@@ -1258,6 +1258,10 @@ var TestActiveSyncServerMixins = {
var folders = this.server.foldersByType[folderType];
return folders[0];
},
+
+ getFirstFolderWithName: function(folderName) {
+ return this.server.findFolderByName(folderName);
+ },
};
var TestActiveSyncAccountMixins = {
@@ -1425,6 +1429,39 @@ var TestActiveSyncAccountMixins = {
return testFolder;
},
+ do_useExistingFolder: function(folderName, suffix, oldFolder) {
+ var self = this,
+ testFolder = this.T.thing('testFolder', folderName + suffix);
+ testFolder.connActor = this.T.actor('ActiveSyncFolderConn', folderName);
+ testFolder.storageActor = this.T.actor('FolderStorage', folderName);
+ testFolder.messages = null;
+ testFolder._approxMessageCount = 30;
+ testFolder._liveSliceThings = [];
+ this.T.convenienceSetup('find test folder', testFolder, function() {
+ if (self.testServer) {
+ testFolder.serverFolder = self.testServer.getFirstFolderWithName(
+ folderName);
+ testFolder.messages = testFolder.serverFolder.messages;
+ }
+ else {
+ testFolder.serverFolder = null;
+ testFolder.messages = null;
+ }
+ testFolder.mailFolder =
+ self.testUniverse.allFoldersSlice.getFirstFolderWithName(folderName);
+ testFolder.id = testFolder.mailFolder.id;
+ if (oldFolder)
+ testFolder.messages = oldFolder.messages;
+
+ testFolder.connActor.__attachToLogger(
+ self.testUniverse.__folderConnLoggerSoup[testFolder.id]);
+ testFolder.storageActor.__attachToLogger(
+ self.testUniverse.__folderStorageLoggerSoup[testFolder.id]);
+ });
+ return testFolder;
+ },
+
+
// copy-paste-modify of the IMAP by-name variant
do_useExistingFolderWithType: function(folderType, suffix, oldFolder) {
var self = this,
@@ -1437,7 +1474,6 @@ var TestActiveSyncAccountMixins = {
testFolder._approxMessageCount = 30;
testFolder._liveSliceThings = [];
this.T.convenienceSetup(this, 'find test folder', testFolder, function() {
- self.expect_foundFolder(true);
if (self.testServer) {
testFolder.serverFolder = self.testServer.getFirstFolderWithType(
folderType);
@@ -1449,7 +1485,6 @@ var TestActiveSyncAccountMixins = {
}
testFolder.mailFolder =
self.testUniverse.allFoldersSlice.getFirstFolderWithType(folderType);
- self._logger.foundFolder(!!testFolder.mailFolder, testFolder.mailFolder);
testFolder.id = testFolder.mailFolder.id;
if (oldFolder)
testFolder.messages = oldFolder.messages;
@@ -1526,10 +1561,46 @@ var TestActiveSyncAccountMixins = {
totalMessageCount += einfo.count;
if (this.universe.online) {
testFolder.connActor.expect_syncDateRange_begin(null, null, null);
+ // TODO: have filterType and recreateFolder be specified in extraFlags
+ // for consistency with IMAP.
if (einfo.filterType)
testFolder.connActor.expect_inferFilterType(einfo.filterType);
- testFolder.connActor.expect_syncDateRange_end(
- einfo.full, einfo.flags, einfo.deleted);
+ if (einfo.recreateFolder) {
+ this.eAccount.expect_recreateFolder(testFolder.id);
+ this.eAccount.expect_saveAccountState();
+
+ var oldConnActor = testFolder.connActor;
+ oldConnActor.expect_syncDateRange_end(null, null, null);
+
+ // Give the new actor a good name.
+ var existingActorMatch =
+ /^([^#]+)(?:#(\d+))?$/.exec(oldConnActor.__name),
+ newActorName;
+ if (existingActorMatch[2])
+ newActorName = existingActorMatch[1] + '#' +
+ (parseInt(existingActorMatch[2], 10) + 1);
+ else
+ newActorName = existingActorMatch[1] + '#2';
+ // Because only one actor will be created in this process, we don't
+ // need to reach into the 'soup' to establish the link and the test
+ // infrastructure will do it automatically for us.
+ var newConnActor = this.T.actor('ActiveSyncFolderConn',
+ newActorName),
+ newStorageActor = this.T.actor('FolderStorage', newActorName);
+ this.RT.reportActiveActorThisStep(newConnActor);
+ this.RT.reportActiveActorThisStep(newStorageActor);
+
+ newConnActor.expect_syncDateRange_begin(null, null, null);
+ newConnActor.expect_syncDateRange_end(
+ einfo.full, einfo.flags, einfo.deleted);
+
+ testFolder.connActor = newConnActor;
+ testFolder.storageActor = newStorageActor;
+ }
+ else {
+ testFolder.connActor.expect_syncDateRange_end(
+ einfo.full, einfo.flags, einfo.deleted);
+ }
}
}
}
View
3  data/lib/node-tls.js
@@ -14,7 +14,8 @@ define(
exports.connect = function(port, host, wuh, onconnect) {
var socky = new $net.NetSocket(port, host, true);
- socky.on('connect', onconnect);
+ if (onconnect)
+ socky.on('connect', onconnect);
return socky;
};
View
634 test/activesync_server.js
@@ -20,7 +20,10 @@ function encodeWBXML(wbxml) {
* @return the WBXML Reader
*/
function decodeWBXML(stream) {
+ if (!stream.available())
+ return null;
let str = NetUtil.readInputStreamToString(stream, stream.available());
+
let bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++)
bytes[i] = str.charCodeAt(i);
@@ -28,6 +31,17 @@ function decodeWBXML(stream) {
return new $_wbxml.Reader(bytes, $_ascp);
}
+/**
+ * Create a new ActiveSync folder.
+ *
+ * @param server the ActiveSyncServer object to associate this folder with
+ * @param name the folder's name
+ * @param type (optional) the folder's type, as an enum from
+ * FolderHierarchy.Enums.Type
+ * @param parent (optional) the folder to contain this folder
+ * @param args (optional) arguments to pass to makeMessages() to generate
+ * initial messages for this folder
+ */
function ActiveSyncFolder(server, name, type, parent, args) {
this.server = server;
this.name = name;
@@ -48,6 +62,7 @@ function ActiveSyncFolder(server, name, type, parent, args) {
}
this.messages = this.server.msgGen.makeMessages(args);
+ this.messages.sort(function(a, b) { return b.date - a.date; });
this._nextMessageSyncId = 1;
this._messageSyncStates = {};
@@ -62,6 +77,13 @@ ActiveSyncFolder.prototype = {
5: 30 * 86400 * 1000,
},
+ /**
+ * Check if a message is in a given filter range.
+ *
+ * @param filterType the filter type to check
+ * @param message a message object, created by messageGenerator.js
+ * @return true if the message is in the filter range, false otherwise
+ */
_messageInFilterRange: function(filterType, message) {
return filterType === $_ascp.AirSync.Enums.FilterType.NoFilter ||
(this.server._clock - this.filterTypeToMS[filterType] <=
@@ -69,6 +91,49 @@ ActiveSyncFolder.prototype = {
},
/**
+ * Add a single message to this folder.
+ *
+ * @param args either a message object created by messageGenerator.js, or
+ * an object of arguments to pass to makeMessage()
+ * @return the newly-added message
+ */
+ addMessage: function(args) {
+ let newMessage = args instanceof SyntheticPart ? args :
+ this.server.msgGen.makeMessage(args);
+ this.messages.unshift(newMessage);
+ this.messages.sort(function(a, b) { return b.date - a.date; });
+
+ for (let [,syncState] in Iterator(this._messageSyncStates)) {
+ if (this._messageInFilterRange(syncState.filterType, newMessage))
+ syncState.commands.push({ type: 'add', message: newMessage });
+ }
+
+ return newMessage;
+ },
+
+ /**
+ * Add an array of messages to this folder.
+ *
+ * @param args either an array of message objects created by
+ * messageGenerator.js, or an object of arguments to pass to
+ * makeMessages()
+ * @return the newly-added messages
+ */
+ addMessages: function(args) {
+ let newMessages = Array.isArray(args) ? args :
+ this.server.msgGen.makeMessages(args);
+ this.messages.unshift.apply(this.messages, newMessages);
+ this.messages.sort(function(a, b) { return b.date - a.date; });
+
+ for (let [,syncState] in Iterator(this._messageSyncStates)) {
+ for (let message of newMessages)
+ syncState.commands.push({ type: 'add', message: message });
+ }
+
+ return newMessages;
+ },
+
+ /**
* Find a message object by its server ID.
*
* @param id the ServerId for the message
@@ -82,64 +147,161 @@ ActiveSyncFolder.prototype = {
return null;
},
- addMessage: function(args) {
- let newMessage = this.server.msgGen.makeMessage(args);
- this.messages.unshift(newMessage);
- this.messages.sort(function(a, b) { return a.date < b.date; });
+ /**
+ * Modify a message in this folder.
+ *
+ * @param message the message to modify
+ * @param changes an object of changes to make; currently supports |read| (a
+ * boolean), and |flag| (a string)
+ */
+ changeMessage: function(message, changes) {
+ if ('read' in changes)
+ message.metaState.read = changes.read;
+ if ('flag' in changes)
+ message.metaState.flag = changes.flag;
for (let [,syncState] in Iterator(this._messageSyncStates)) {
+ // TODO: Handle the case where we already have this message in the command
+ // list.
if (this._messageInFilterRange(syncState.filterType, message))
- syncState.changes.push({ type: 'add', message: newMessage });
+ syncState.commands.push({ type: 'change', messageId: message.messageId,
+ changes: changes });
}
-
- return newMessage;
},
- addMessages: function(args) {
- let newMessages = this.server.msgGen.makeMessages(args);
- this.messages.unshift.apply(this.messages, newMessages);
- this.messages.sort(function(a, b) { return a.date < b.date; });
+ /**
+ * Remove a message in this folder by its id.
+ *
+ * @param id the message's id
+ * @return the deleted message, or null if the message wasn't found
+ */
+ removeMessageById: function(id) {
+ for (let [i, message] in Iterator(this.messages)) {
+ if (message.messageId === id) {
+ this.messages.splice(i, 1);
+
+ for (let [,syncState] in Iterator(this._messageSyncStates)) {
+ if (this._messageInFilterRange(syncState.filterType, message))
+ syncState.commands.push({ type: 'delete',
+ messageId: message.messageId });
+ }
- for (let [,syncState] in Iterator(this._messageSyncStates)) {
- for (let message of newMessages)
- syncState.changes.push({ type: 'add', message: message });
+ return message;
+ }
}
+ return null;
+ },
- return newMessages;
+ /**
+ * Create a unique SyncKey.
+ */
+ _createSyncKey: function() {
+ return 'messages-' + (this._nextMessageSyncId++) + '/' + this.id;
},
- createSyncState: function(oldSyncKey, filterType) {
- if (oldSyncKey !== '0' &&
- (!this._messageSyncStates.hasOwnProperty(oldSyncKey) ||
- this._messageSyncStates[oldSyncKey].filterType !== filterType))
- return '0';
+ /**
+ * Create a new sync state for this folder. Sync states keep track of the
+ * changes in the folder that occur since the creation of the sync state.
+ * These changes are filtered by the |filterType|, which limits the date
+ * range of changes to listen for.
+ *
+ * A sync state can also be populated with an initial array of commands, or
+ * "initial" to add all the messages in the folder to the state (subject to
+ * |filterType|).
+ *
+ * Commands are ordered in the sync state from oldest to newest, to mimic
+ * Hotmail's behavior. However, this implementation doesn't currently coalesce
+ * multiple changes into a single command.
+ *
+ * @param filterType the filter type for this sync state
+ * @param commands (optional) an array of commands to add to the sync state
+ * immediately, or the string "initial" to add all the current messages
+ * in the folder
+ * @return the SyncKey associated with this sync state
+ */
+ createSyncState: function(filterType, commands) {
+ if (commands === 'initial') {
+ commands = [];
+ // Go in reverse, since messages are stored in descending date order, but
+ // we want ascending date order.
+ for (let i = this.messages.length - 1; i >= 0; i--) {
+ if (this._messageInFilterRange(filterType, this.messages[i]))
+ commands.push({ type: 'add', message: this.messages[i] });
+ }
+ }
- let syncKey = 'messages-' + (this._nextMessageSyncId++) + '/' + this.id;
+ let syncKey = this._createSyncKey();
let syncState = this._messageSyncStates[syncKey] = {
filterType: filterType,
- changes: []
+ commands: commands || []
};
- if (oldSyncKey === '0') {
- for (let message of this.messages) {
- if (this._messageInFilterRange(syncState.filterType, message))
- syncState.changes.push({ type: 'add', message: message });
- }
- }
return syncKey;
},
+ /**
+ * Recreate a sync state by giving it a new SyncKey and adding it back to our
+ * list of tracked states.
+ *
+ * @param syncState the old sync state to add back in
+ * @return the SyncKey associated with this sync state
+ */
+ recreateSyncState: function(syncState) {
+ let syncKey = this._createSyncKey();
+ this._messageSyncStates[syncKey] = syncState;
+ return syncKey;
+ },
+
+ /**
+ * Remove a sync state from our list (thus causing it to stop listening for
+ * new changes) and return it.
+ *
+ * @param syncKey the SyncKey associated with the sync state
+ * @return the sync state
+ */
takeSyncState: function(syncKey) {
let syncState = this._messageSyncStates[syncKey];
delete this._messageSyncStates[syncKey];
return syncState;
},
- peekSyncState: function(syncKey) {
- return this._messageSyncStates[syncKey];
+ /**
+ * Check if the folder knows about a particular sync state.
+ *
+ * @param syncKey the SyncKey associated with the sync state
+ * @return true if the folder knows about this sycn state, false otherwise
+ */
+ hasSyncState: function(syncKey) {
+ return this._messageSyncStates.hasOwnProperty(syncKey);
+ },
+
+ /**
+ * Get the filter type for a given sync state.
+ *
+ * @param syncKey the SyncKey associated with the sync state
+ * @return the filter type
+ */
+ filterTypeForSyncState: function(syncKey) {
+ return this._messageSyncStates[syncKey].filterType;
+ },
+
+ /**
+ * Get the number of pending commands for a given sync state.
+ *
+ * @param syncKey the SyncKey associated with the sync state
+ * @return the number of commands
+ */
+ numCommandsForSyncState: function(syncKey) {
+ return this._messageSyncStates[syncKey].commands.length;
},
};
+/**
+ * Create a new ActiveSync server instance. Currently, this server only supports
+ * one user.
+ *
+ * @param startDate (optional) a timestamp to set the server's clock to
+ */
function ActiveSyncServer(startDate) {
this.server = new HttpServer();
this.msgGen = new MessageGenerator();
@@ -163,6 +325,7 @@ function ActiveSyncServer(startDate) {
this.addFolder('Inbox', folderType.DefaultInbox);
this.addFolder('Sent Mail', folderType.DefaultSent, null, {count: 5});
+ this.addFolder('Trash', folderType.DefaultDeleted, null, {count: 0});
this.logRequest = null;
this.logRequestBody = null;
@@ -200,6 +363,16 @@ ActiveSyncServer.prototype = {
12: 'normal', // Mail
},
+ /**
+ * Create a new folder on this server.
+ *
+ * @param name the folder's name
+ * @param type (optional) the folder's type, as an enum from
+ * FolderHierarchy.Enums.Type
+ * @param parent (optional) the folder to contain this folder
+ * @param args (optional) arguments to pass to makeMessages() to generate
+ * initial messages for this folder
+ */
addFolder: function(name, type, parent, args) {
if (type && !this._folderTypes.hasOwnProperty(type))
throw new Error('Invalid folder type');
@@ -214,6 +387,12 @@ ActiveSyncServer.prototype = {
return folder;
},
+ /**
+ * Handle incoming requests.
+ *
+ * @param request the nsIHttpRequest
+ * @param response the nsIHttpResponse
+ */
_commandHandler: function(request, response) {
if (this.logRequest)
this.logRequest(request);
@@ -234,7 +413,16 @@ ActiveSyncServer.prototype = {
}
try {
- this['_handleCommand_' + query.Cmd](request, query, response);
+ let wbxmlResponse = this['_handleCommand_' + query.Cmd](
+ request, query, response);
+
+ if (wbxmlResponse) {
+ response.setStatusLine('1.1', 200, 'OK');
+ response.setHeader('Content-Type', 'application/vnd.ms-sync.wbxml');
+ response.write(encodeWBXML(wbxmlResponse));
+ if (this.logResponse)
+ this.logResponse(request, response, wbxmlResponse);
+ }
} catch(e) {
if (this.logResponseError)
this.logResponseError(e + '\n' + e.stack);
@@ -245,6 +433,13 @@ ActiveSyncServer.prototype = {
}
},
+ /**
+ * Handle the OPTIONS request, returning our list of supported commands, and
+ * other useful details.
+ *
+ * @param request the nsIHttpRequest
+ * @param response the nsIHttpResponse
+ */
_options: function(request, response) {
response.setStatusLine('1.1', 200, 'OK');
response.setHeader('Public', 'OPTIONS,POST');
@@ -263,6 +458,14 @@ ActiveSyncServer.prototype = {
this.logResponse(request, response);
},
+ /**
+ * Handle the FolderSync command. This entails keeping track of which folders
+ * the client knows about using folder SyncKeys.
+ *
+ * @param request the nsIHttpRequest
+ * @param query an object of URL query parameters
+ * @param response the nsIHttpResponse
+ */
_handleCommand_FolderSync: function(request, query, response) {
const fh = $_ascp.FolderHierarchy.Tags;
const folderType = $_ascp.FolderHierarchy.Enums.Type;
@@ -319,19 +522,28 @@ ActiveSyncServer.prototype = {
w .etag(fh.Changes)
.etag(fh.FolderSync);
- response.setStatusLine('1.1', 200, 'OK');
- response.setHeader('Content-Type', 'application/vnd.ms-sync.wbxml');
- response.write(encodeWBXML(w));
- if (this.logResponse)
- this.logResponse(request, response, w);
+ return w;
},
+ /**
+ * Handle the Sync command. This is the meat of the ActiveSync server. We need
+ * to keep track of SyncKeys for each folder (handled in ActiveSyncFolder),
+ * respond to commands from the client, and update clients with any changes
+ * we know about.
+ *
+ * @param request the nsIHttpRequest
+ * @param query an object of URL query parameters
+ * @param response the nsIHttpResponse
+ */
_handleCommand_Sync: function(request, query, response) {
const as = $_ascp.AirSync.Tags;
const asEnum = $_ascp.AirSync.Enums;
- let syncKey, nextSyncKey, collectionId,
- filterType = asEnum.FilterType.NoFilter;
+ let syncKey, collectionId, getChanges,
+ server = this,
+ deletesAsMoves = true,
+ filterType = asEnum.FilterType.NoFilter,
+ clientCommands = [];
let e = new $_wbxml.EventParser();
const base = [as.Sync, as.Collections, as.Collection];
@@ -342,21 +554,136 @@ ActiveSyncServer.prototype = {
e.addEventListener(base.concat(as.CollectionId), function(node) {
collectionId = node.children[0].textContent;
});
+ e.addEventListener(base.concat(as.DeletesAsMoves), function(node) {
+ deletesAsMoves = node.children.length === 0 ||
+ node.children[0].textContent === '1';
+ });
+ e.addEventListener(base.concat(as.GetChanges), function(node) {
+ getChanges = node.children.length === 0 ||
+ node.children[0].textContent === '1';
+ });
e.addEventListener(base.concat(as.Options, as.FilterType), function(node) {
filterType = node.children[0].textContent;
});
+ e.addEventListener(base.concat(as.Commands, as.Change), function(node) {
+ let command = { type: 'change' };
+ for (let child of node.children) {
+ switch(child.tag) {
+ case as.ServerId:
+ command.serverId = child.children[0].textContent;
+ break;
+ case as.ApplicationData:
+ command.changes = server._parseEmailChange(child);
+ break;
+ }
+ }
+ clientCommands.push(command);
+ });
+ e.addEventListener(base.concat(as.Commands, as.Delete), function(node) {
+ let command = { type: 'delete' };
+ for (let child of node.children) {
+ switch(child.tag) {
+ case as.ServerId:
+ command.serverId = child.children[0].textContent;
+ break;
+ }
+ }
+ clientCommands.push(command);
+ });
- let reader = decodeWBXML(request.bodyInputStream);
+ let reader = decodeWBXML(request.bodyInputStream) ||
+ this._cachedSyncRequest;
if (this.logRequestBody)
this.logRequestBody(reader);
e.run(reader);
+ // If GetChanges wasn't specified, it defaults to true when the SyncKey is
+ // non-zero, and false when the SyncKey is zero.
+ if (getChanges === undefined)
+ getChanges = syncKey !== '0';
+
+ // Now it's time to actually perform the sync operation!
+
let folder = this._findFolderById(collectionId),
- nextSyncKey = folder.createSyncState(syncKey, filterType),
- syncState = syncKey !== '0' ? folder.takeSyncState(syncKey) : null;
+ syncState = null, status, nextSyncKey;
- let status = nextSyncKey === '0' ? asEnum.Status.InvalidSyncKey :
- asEnum.Status.Success;
+ // - Get an initial sync key.
+ if (syncKey === '0') {
+ // Initial sync can't change anything, in either direction.
+ if (getChanges || clientCommands.length) {
+ let w = new $_wbxml.Writer('1.3', 1, 'UTF-8');
+ w.stag(as.Sync)
+ .tag(as.Status, asEnum.Status.ProtocolError)
+ .etag();
+ return w;
+ }
+
+ nextSyncKey = folder.createSyncState(filterType, 'initial');
+ status = asEnum.Status.Success;
+ }
+ // - Check for invalid sync keys.
+ else if (!folder.hasSyncState(syncKey) ||
+ (filterType &&
+ filterType !== folder.filterTypeForSyncState(syncKey))) {
+ nextSyncKey = '0';
+ status = asEnum.Status.InvalidSyncKey;
+ }
+ // - Perform a sync operation where the client has requested some changes.
+ else if (clientCommands.length) {
+ // Save off the sync state so that our commands don't touch it.
+ syncState = folder.takeSyncState(syncKey);
+
+ // Run any commands the client sent.
+ for (let command of clientCommands) {
+ if (command.type === 'change') {
+ let message = folder.findMessageById(command.serverId);
+ folder.changeMessage(message, command.changes);
+ }
+ else if (command.type === 'delete') {
+ let message = folder.removeMessageById(command.serverId);
+ if (deletesAsMoves)
+ this.foldersByType['trash'][0].addMessage(message);
+ }
+ }
+
+ // Create the next sync state, with a new SyncKey.
+ if (getChanges) {
+ // Create a fresh sync state.
+ nextSyncKey = folder.createSyncState(syncState.filterType);
+ }
+ else {
+ // Create a new state with the old one's command list, and clear out
+ // our syncState so we don't return any changes.
+ nextSyncKey = folder.recreateSyncState(syncState);
+ syncState = null;
+ }
+
+ status = asEnum.Status.Success;
+ }
+ else if (getChanges) {
+ if (folder.numCommandsForSyncState(syncKey)) {
+ // There are pending changes, so create a fresh sync state.
+ syncState = folder.takeSyncState(syncKey);
+ nextSyncKey = folder.createSyncState(syncState.filterType);
+ status = asEnum.Status.Success;
+ }
+ else {
+ // There are no changes, so cache the sync request and return an empty
+ // response.
+ response.setStatusLine('1.1', 200, 'OK');
+ reader.rewind();
+ this._cachedSyncRequest = reader;
+ return;
+ }
+ }
+ // - A sync without changes requested and no commands to run -> error!
+ else {
+ let w = new $_wbxml.Writer('1.3', 1, 'UTF-8');
+ w.stag(as.Sync)
+ .tag(as.Status, asEnum.Status.ProtocolError)
+ .etag();
+ return w;
+ }
let w = new $_wbxml.Writer('1.3', 1, 'UTF-8');
@@ -367,36 +694,76 @@ ActiveSyncServer.prototype = {
.tag(as.CollectionId, collectionId)
.tag(as.Status, status);
- if (syncState && syncState.changes.length) {
+ if (syncState && syncState.commands.length) {
w.stag(as.Commands);
- for (let change of syncState.changes) {
- if (change.type === 'add') {
+ for (let command of syncState.commands) {
+ if (command.type === 'add') {
w.stag(as.Add)
- .tag(as.ServerId, change.message.messageId)
- .stag(as.ApplicationData);
+ .tag(as.ServerId, command.message.messageId)
+ .stag(as.ApplicationData);
- this._writeEmail(w, change.message);
+ this._writeEmail(w, command.message);
w .etag(as.ApplicationData)
.etag(as.Add);
}
+ else if (command.type === 'change') {
+ w.stag(as.Change)
+ .tag(as.ServerId, command.messageId)
+ .stag(as.ApplicationData);
+
+ if ('read' in command.changes)
+ w.tag(em.Read, command.changes.read ? '1' : '0');
+
+ if ('flag' in command.changes)
+ w.stag(em.Flag)
+ .tag(em.Status, command.changes.flag)
+ .etag();
+
+ w .etag(as.ApplicationData)
+ .etag(as.Change);
+ }
+ else if (command.type === 'delete') {
+ w.stag(as.Delete)
+ .tag(as.ServerId, command.messageId)
+ .etag(as.Delete);
+ }
}
w.etag(as.Commands);
}
+ if (clientCommands.length) {
+ w.stag(as.Responses);
+
+ for (let command of clientCommands) {
+ if (command.type === 'change') {
+ w.stag(as.Change)
+ .tag(as.ServerId, command.serverId)
+ .tag(as.Status, asEnum.Status.Success)
+ .etag(as.Change);
+ }
+ }
+
+ w.etag(as.Responses);
+ }
+
w .etag(as.Collection)
.etag(as.Collections)
.etag(as.Sync);
- response.setStatusLine('1.1', 200, 'OK');
- response.setHeader('Content-Type', 'application/vnd.ms-sync.wbxml');
- response.write(encodeWBXML(w));
- if (this.logResponse)
- this.logResponse(request, response, w);
+ return w;
},
+ /**
+ * Handle the ItemOperations command. Mainly, this is used to get message
+ * bodies and attachments.
+ *
+ * @param request the nsIHttpRequest
+ * @param query an object of URL query parameters
+ * @param response the nsIHttpResponse
+ */
_handleCommand_ItemOperations: function(request, query, response) {
const io = $_ascp.ItemOperations.Tags;
const as = $_ascp.AirSync.Tags;
@@ -451,13 +818,17 @@ ActiveSyncServer.prototype = {
w .etag(io.Response)
.etag(io.ItemOperations);
- response.setStatusLine('1.1', 200, 'OK');
- response.setHeader('Content-Type', 'application/vnd.ms-sync.wbxml');
- response.write(encodeWBXML(w));
- if (this.logResponse)
- this.logResponse(request, response, w);
+ return w;
},
+ /**
+ * Handle the GetItemEstimate command. This gives the client the number of
+ * changes to expect from a Sync request.
+ *
+ * @param request the nsIHttpRequest
+ * @param query an object of URL query parameters
+ * @param response the nsIHttpResponse
+ */
_handleCommand_GetItemEstimate: function(request, query, response) {
const ie = $_ascp.ItemEstimate.Tags;
const as = $_ascp.AirSync.Tags;
@@ -491,11 +862,11 @@ ActiveSyncServer.prototype = {
status = ieStatus.InvalidCollection;
else if (syncKey === '0')
status = ieStatus.NoSyncState;
- else if (!(syncState = folder.peekSyncState(syncKey)))
+ else if (!folder.hasSyncState(syncKey))
status = ieStatus.InvalidSyncKey;
else {
status = ieStatus.Success;
- estimate = syncState.changes.length;
+ estimate = folder.numCommandsForSyncState(syncKey);
}
let w = new $_wbxml.Writer('1.3', 1, 'UTF-8');
@@ -512,11 +883,91 @@ ActiveSyncServer.prototype = {
w .etag(ie.Response)
.etag(ie.GetItemEstimate);
- response.setStatusLine('1.1', 200, 'OK');
- response.setHeader('Content-Type', 'application/vnd.ms-sync.wbxml');
- response.write(encodeWBXML(w));
- if (this.logResponse)
- this.logResponse(request, response, w);
+ return w;
+ },
+
+ /**
+ * Handle the MoveItems command. This lets clients move messages between
+ * folders. Note that they'll have to get up-to-date via a Sync request
+ * afterward.
+ *
+ * @param request the nsIHttpRequest
+ * @param query an object of URL query parameters
+ * @param response the nsIHttpResponse
+ */
+ _handleCommand_MoveItems: function(request, query, response) {
+ const mo = $_ascp.Move.Tags;
+ const moStatus = $_ascp.Move.Enums.Status;
+
+ let moves = [];
+ let e = new $_wbxml.EventParser();
+ e.addEventListener([mo.MoveItems, mo.Move], function(node) {
+ let move = {};
+
+ for (let child of node.children) {
+ let textContent = child.children[0].textContent;
+
+ switch (child.tag) {
+ case mo.SrcMsgId:
+ move.srcMessageId = textContent;
+ break;
+ case mo.SrcFldId:
+ move.srcFolderId = textContent;
+ break;
+ case mo.DstFldId:
+ move.destFolderId = textContent;
+ break;
+ }
+ }
+
+ moves.push(move);
+ });
+ let reader = decodeWBXML(request.bodyInputStream);
+ if (this.logRequestBody)
+ this.logRequestBody(reader);
+ e.run(reader);
+
+ let w = new $_wbxml.Writer('1.3', 1, 'UTF-8');
+ w.stag(mo.MoveItems);
+
+ for (let move of moves) {
+ let srcFolder = this._findFolderById(move.srcFolderId),
+ destFolder = this._findFolderById(move.destFolderId),
+ status;
+
+ if (!srcFolder) {
+ status = moStatus.InvalidSourceId;
+ }
+ else if (!destFolder) {
+ status = moStatus.InvalidDestId;
+ }
+ else if (srcFolder === destFolder) {
+ status = moStatus.SourceIsDest;
+ }
+ else {
+ let message = srcFolder.removeMessageById(move.srcMessageId);
+
+ if (!message) {
+ status = moStatus.InvalidSourceId;
+ }
+ else {
+ status = moStatus.Success;
+ destFolder.addMessage(message);
+ }
+ }
+
+ w.stag(mo.Response)
+ .tag(mo.SrcMsgId, move.srcMessageId)
+ .tag(mo.Status, status);
+
+ if (status === moStatus.Success)
+ w.tag(mo.DstMsgId, move.srcMessageId)
+
+ w.etag(mo.Response);
+ }
+
+ w.etag(mo.MoveItems);
+ return w;
},
/**
@@ -534,6 +985,20 @@ ActiveSyncServer.prototype = {
},
/**
+ * Find a folder object by its server ID.
+ *
+ * @param id the CollectionId for the folder
+ * @return the ActiveSyncFolder object, or null if no folder was found
+ */
+ findFolderByName: function(name) {
+ for (let folder of this._folders) {
+ if (folder.name === name)
+ return folder;
+ }
+ return null;
+ },
+
+ /**
* Write the WBXML for an individual message.
*
* @param w the WBXML writer
@@ -561,7 +1026,10 @@ ActiveSyncServer.prototype = {
.tag(em.Subject, message.subject)
.tag(em.DateReceived, new Date(message.date).toISOString())
.tag(em.Importance, '1')
- .tag(em.Read, '0');
+ .tag(em.Read, message.metaState.read ? '1' : '0')
+ .stag(em.Flag)
+ .tag(em.Status, message.metaState.flag || '0')
+ .etag();
if (attachments.length) {
w.stag(asb.Attachments);
@@ -590,5 +1058,35 @@ ActiveSyncServer.prototype = {
.tag(asb.Truncated, '0')
.tag(asb.Data, bodyPart.body)
.etag();
- }
+ },
+
+ /**
+ * Parse the WBXML for a client-side email change command.
+ *
+ * @param node the (fully-parsed) ApplicationData node and its children
+ * @return an object enumerating the changes requested
+ */
+ _parseEmailChange: function(node) {
+ const em = $_ascp.Email.Tags;
+ let changes = {};
+
+ for (let child of node.children) {
+ switch (child.tag) {
+ case em.Read:
+ changes.read = child.children[0].textContent === '1';
+ break;
+ case em.Flag:
+ for (let grandchild of child.children) {
+ switch (grandchild.tag) {
+ case em.Status:
+ changes.flag = grandchild.children[0].textContent;
+ break;
+ }
+ }
+ break;
+ }
+ }
+
+ return changes;
+ },
};
View
26 test/unit/test_activesync_general.js
@@ -87,6 +87,27 @@ TD.commonCase('folder sync', function(T) {
{ count: 5, full: 1, flags: 0, deleted: 0 },
{ top: true, bottom: true, grow: false });
+ T.group('sync detects deletions');
+ T.action('blah', testAccount, eSync, function() {
+ var headers = folderView.slice.items,
+ toDelete = headers[0],
+ toDeleteId = fullSyncFolder.messages[0].messageId,
+ expectedValues = { count: 4, full: 0, flags: 0, deleted: 1 },
+ checkExpected = { changes: [], deletions: [toDelete] },
+ expectedFlags = { top: true, bottom: true, grow: false };
+
+ fullSyncFolder.serverFolder.removeMessageById(toDeleteId);
+ var totalExpected = testAccount._expect_dateSyncs(folderView.testFolder,
+ expectedValues);
+ testAccount.expect_messagesReported(totalExpected);
+ testAccount.expect_headerChanges(folderView, checkExpected, expectedFlags);
+
+ testAccount._expect_storage_mutexed(folderView.testFolder.storageActor,
+ 'refresh');
+
+ folderView.slice.refresh();
+ });
+
/**
* Perform a folder sync where our initial time fetch window contains more
* messages than we want and there are even more messages beyond.
@@ -147,6 +168,11 @@ TD.commonCase('folder sync', function(T) {
eSync.event('roundtrip');
});
});
+ testAccount.do_viewFolder(
+ 'syncs', partialSyncFolder,
+ { count: INITIAL_FILL_SIZE, full: 60, flags: 0, deleted: 0,
+ recreateFolder: true },
+ { top: true, bottom: false, grow: false });
T.group('manual sync range');
var manualRangeFolder = testAccount.do_createTestFolder(
View
639 test/unit/test_activesync_mutation.js
@@ -0,0 +1,639 @@
+/**
+ * Test our mutation operations: altering message flags, moving messages,
+ * and deleting messages (which will frequently just involve moving a message
+ * to the trash folder).
+ *
+ * We want to ensure that our mutation operations:
+ * - Apply changes locally to the database / messages upon issue, so that the
+ * user can see the result of their changes almost immediately even when they
+ * are offline or experiencing high latency.
+ *
+ * - Are undoable. Every operation should be able to be reversed.
+ *
+ * - Are no-ops from a server perspective when undone before being played to the
+ * server.
+ *
+ * - Gracefully handle the disappearance of messages, both in changes
+ * originating from the server (presumably from another client), as well as
+ * local manipulations like moving a message.
+ *
+ **/
+
+load('resources/loggest_test_framework.js');
+const $wbxml = require('wbxml');
+const $ascp = require('activesync/codepages');
+const FilterType = $ascp.AirSync.Enums.FilterType;
+
+var TD = $tc.defineTestsFor(
+ { id: 'test_activesync_mutation' }, null, [$th_imap.TESTHELPER], ['app']);
+
+TD.commonCase('mutate flags', function(T) {
+ T.group('setup');
+ var testUniverse = T.actor('testUniverse', 'U'),
+ testAccount = T.actor('testAccount', 'A', { universe: testUniverse }),
+ eSync = T.lazyLogger('sync'),
+ numMessages = 4;
+
+ var testFolder = testAccount.do_createTestFolder(
+ 'test_mutation_flags',
+ { count: numMessages, age_incr: { days: 1 } });
+ var folderView = testAccount.do_openFolderView(
+ 'folderView', testFolder,
+ { count: numMessages, full: numMessages, flags: 0, deleted: 0,
+ filterType: FilterType.NoFilter },
+ { top: true, bottom: true, grow: false });
+
+ var doHeaderExps = null, undoHeaderExps = null, undoOps = null,
+ applyManips = null;
+
+ /**
+ * This tests our local modifications and that our state stays the same once
+ * we have told the server our changes and then synced against them.
+ *
+ * TODO: We want to support custom-tags, but it's not a v1 req, so we're
+ * punting it.
+ */
+ T.group('offline manipulation; released to server');
+ testUniverse.do_pretendToBeOffline(true);
+ T.action('manipulate flags, hear local changes, no network use by',
+ testAccount, testAccount.eOpAccount, function() {
+ // by mentioning testAccount we ensure that we will assert if we see a
+ // reuseConnection from it.
+ var headers = folderView.slice.items,
+ toMarkRead = headers[1],
+ toStar = headers[2],
+ toStarAndMarkRead = headers[3];
+
+ applyManips = function applyManips() {
+ undoOps = [];
+
+ undoOps.push(toMarkRead.setRead(true));
+ undoOps.push(toStar.setStarred(true));
+ // this normally would not be a single transaction...
+ undoOps.push(toStarAndMarkRead.modifyTags(['\\Seen', '\\Flagged']));
+ };
+ applyManips();
+ for (var nOps = undoOps.length; nOps > 0; nOps--) {
+ testAccount.expect_runOp(
+ 'modtags',
+ { local: true, server: false, save: true });
+ }
+
+ doHeaderExps = {
+ changes: [
+ [toStar, 'isStarred', true],
+ [toMarkRead, 'isRead', true],
+ [toStarAndMarkRead, 'isStarred', true, 'isRead', true],
+ ],
+ deletions: []
+ };
+ undoHeaderExps = {
+ changes: [
+ [toStar, 'isStarred', false],
+ [toMarkRead, 'isRead', false],