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

698 lines
25 KiB
JavaScript
Executable file

/* Copyright 2012 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function (root, factory) {
if (typeof exports === 'object')
module.exports = factory(require('wbxml'), require('activesync/codepages'));
else if (typeof define === 'function' && define.amd)
define(['wbxml', 'activesync/codepages'], factory);
else
root.ActiveSyncProtocol = factory(WBXML, ActiveSyncCodepages);
}(this, function(WBXML, ASCP) {
'use strict';
var exports = {};
var USER_AGENT = 'JavaScript ActiveSync (jsas) Client';
function nullCallback() {}
/**
* Create a constructor for a custom error type that works like a built-in
* Error.
*
* @param name the string name of the error
* @param parent (optional) a parent class for the error, defaults to Error
* @param extraArgs an array of extra arguments that can be passed to the
* constructor of this error type
* @return the constructor for this error
*/
function makeError(name, parent, extraArgs) {
function CustomError() {
// Try to let users call this as CustomError(...) without the "new". This
// is imperfect, and if you call this function directly and give it a
// |this| that's a CustomError, things will break. Don't do it!
var self = this instanceof CustomError ?
this : Object.create(CustomError.prototype);
var tmp = Error();
var offset = 1;
self.stack = tmp.stack.substring(tmp.stack.indexOf('\n') + 1);
self.message = arguments[0] || tmp.message;
if (extraArgs) {
offset += extraArgs.length;
for (var i = 0; i < extraArgs.length; i++)
self[extraArgs[i]] = arguments[i+1];
}
var m = /@(.+):(.+)/.exec(self.stack);
self.fileName = arguments[offset] || (m && m[1]) || "";
self.lineNumber = arguments[offset + 1] || (m && m[2]) || 0;
return self;
}
CustomError.prototype = Object.create((parent || Error).prototype);
CustomError.prototype.name = name;
CustomError.prototype.constructor = CustomError;
return CustomError;
}
var AutodiscoverError = makeError('ActiveSync.AutodiscoverError');
exports.AutodiscoverError = AutodiscoverError;
var AutodiscoverDomainError = makeError('ActiveSync.AutodiscoverDomainError',
AutodiscoverError);
exports.AutodiscoverDomainError = AutodiscoverDomainError;
var HttpError = makeError('ActiveSync.HttpError', null, ['status']);
exports.HttpError = HttpError;
function nsResolver(prefix) {
var baseUrl = 'http://schemas.microsoft.com/exchange/autodiscover/';
var ns = {
rq: baseUrl + 'mobilesync/requestschema/2006',
ad: baseUrl + 'responseschema/2006',
ms: baseUrl + 'mobilesync/responseschema/2006',
};
return ns[prefix] || null;
}
function Version(str) {
var details = str.split('.').map(function(x) {
return parseInt(x);
});
this.major = details[0], this.minor = details[1];
}
exports.Version = Version;
Version.prototype = {
eq: function(other) {
if (!(other instanceof Version))
other = new Version(other);
return this.major === other.major && this.minor === other.minor;
},
ne: function(other) {
return !this.eq(other);
},
gt: function(other) {
if (!(other instanceof Version))
other = new Version(other);
return this.major > other.major ||
(this.major === other.major && this.minor > other.minor);
},
gte: function(other) {
if (!(other instanceof Version))
other = new Version(other);
return this.major >= other.major ||
(this.major === other.major && this.minor >= other.minor);
},
lt: function(other) {
return !this.gte(other);
},
lte: function(other) {
return !this.gt(other);
},
toString: function() {
return this.major + '.' + this.minor;
},
};
/**
* Set the Authorization header on an XMLHttpRequest.
*
* @param xhr the XMLHttpRequest
* @param username the username
* @param password the user's password
*/
function setAuthHeader(xhr, username, password) {
var authorization = 'Basic ' + btoa(username + ':' + password);
xhr.setRequestHeader('Authorization', authorization);
}
/**
* Perform autodiscovery for the server associated with this account.
*
* @param aEmailAddress the user's email address
* @param aPassword the user's password
* @param aTimeout a timeout (in milliseconds) for the request
* @param aCallback a callback taking an error status (if any) and the
* server's configuration
* @param aNoRedirect true if autodiscovery should *not* follow any
* specified redirects (typically used when autodiscover has already
* told us about a redirect)
*/
function autodiscover(aEmailAddress, aPassword, aTimeout, aCallback,
aNoRedirect) {
if (!aCallback) aCallback = nullCallback;
var domain = aEmailAddress.substring(aEmailAddress.indexOf('@') + 1);
// The first time we try autodiscovery, we should try to recover from
// AutodiscoverDomainErrors and HttpErrors. The second time, *all* errors
// should be reported to the callback.
do_autodiscover(domain, aEmailAddress, aPassword, aTimeout, aNoRedirect,
function(aError, aConfig) {
if (aError instanceof AutodiscoverDomainError ||
aError instanceof HttpError)
do_autodiscover('autodiscover.' + domain, aEmailAddress, aPassword,
aTimeout, aNoRedirect, aCallback);
else
aCallback(aError, aConfig);
});
}
exports.autodiscover = autodiscover;
/**
* Perform the actual autodiscovery process for a given URL.
*
* @param aHost the host name to attempt autodiscovery for
* @param aEmailAddress the user's email address
* @param aPassword the user's password
* @param aTimeout a timeout (in milliseconds) for the request
* @param aNoRedirect true if autodiscovery should *not* follow any
* specified redirects (typically used when autodiscover has already
* told us about a redirect)
* @param aCallback a callback taking an error status (if any) and the
* server's configuration
*/
function do_autodiscover(aHost, aEmailAddress, aPassword, aTimeout,
aNoRedirect, aCallback) {
var url = 'https://' + aHost + '/autodiscover/autodiscover.xml';
return raw_autodiscover(url, aEmailAddress, aPassword, aTimeout,
aNoRedirect, aCallback);
}
function raw_autodiscover(aUrl, aEmailAddress, aPassword, aTimeout,
aNoRedirect, aCallback) {
var xhr = new XMLHttpRequest({mozSystem: true, mozAnon: true});
xhr.open('POST', aUrl, true);
setAuthHeader(xhr, aEmailAddress, aPassword);
xhr.setRequestHeader('Content-Type', 'text/xml');
xhr.setRequestHeader('User-Agent', USER_AGENT);
xhr.timeout = aTimeout;
xhr.upload.onprogress = xhr.upload.onload = function() {
xhr.timeout = 0;
};
xhr.onload = function() {
if (xhr.status < 200 || xhr.status >= 300)
return aCallback(new HttpError(xhr.statusText, xhr.status));
var uid = Math.random();
self.postMessage({
uid: uid,
type: 'configparser',
cmd: 'accountactivesync',
args: [xhr.responseText, aNoRedirect]
});
self.addEventListener('message', function onworkerresponse(evt) {
var data = evt.data;
if (data.type != 'configparser' || data.cmd != 'accountactivesync' ||
data.uid != uid) {
return;
}
self.removeEventListener(evt.type, onworkerresponse);
var args = data.args;
var config = args[0], error = args[1], redirectedEmail = args[2];
if (error) {
aCallback(new AutodiscoverDomainError(error), config);
} else if (redirectedEmail) {
autodiscover(redirectedEmail, aPassword, aTimeout, aCallback, true);
} else {
aCallback(null, config);
}
});
};
xhr.ontimeout = xhr.onerror = function() {
// Something bad happened in the network layer, so treat this like an HTTP
// error.
aCallback(new HttpError('Error getting Autodiscover URL', null));
};
// TODO: use something like
// http://ejohn.org/blog/javascript-micro-templating/ here?
var postdata =
'<?xml version="1.0" encoding="utf-8"?>\n' +
'<Autodiscover xmlns="' + nsResolver('rq') + '">\n' +
' <Request>\n' +
' <EMailAddress>' + aEmailAddress + '</EMailAddress>\n' +
' <AcceptableResponseSchema>' + nsResolver('ms') +
'</AcceptableResponseSchema>\n' +
' </Request>\n' +
'</Autodiscover>';
xhr.send(postdata);
}
exports.raw_autodiscover = raw_autodiscover;
/**
* Create a new ActiveSync connection.
*
* ActiveSync connections use XMLHttpRequests to communicate with the
* server. These XHRs are created with mozSystem: true and mozAnon: true to,
* respectively, help with CORS, and to ignore the authentication cache. The
* latter is important because 1) it prevents the HTTP auth dialog from
* appearing if the user's credentials are wrong and 2) it allows us to
* connect to the same server as multiple users.
*
* @param aDeviceId (optional) a string identifying this device
* @param aDeviceType (optional) a string identifying the type of this device
*/
function Connection(aDeviceId, aDeviceType) {
this._deviceId = aDeviceId || 'v140Device';
this._deviceType = aDeviceType || 'SmartPhone';
this.timeout = 0;
this._connected = false;
this._waitingForConnection = false;
this._connectionError = null;
this._connectionCallbacks = [];
this.baseUrl = null;
this._username = null;
this._password = null;
this.versions = [];
this.supportedCommands = [];
this.currentVersion = null;
/**
* Debug support function that is called every time an XHR call completes.
* This is intended to be used for logging.
*
* The arguments to the function are:
*
* - type: 'options' if caused by a call to getOptions. 'post' if caused by
* a call to postCommand/postData.
*
* - special: 'timeout' if a timeout error occurred, 'redirect' if the
* status code was 451 and the call is being reissued, 'error' if some
* type of error occurred, or 'ok' on success. Check xhr.status for the
* specific http status code.
*
* - xhr: The XMLHttpRequest used. Use this to check the statusCode,
* statusText, or response headers.
*
* - params: The object dictionary of parameters encoded into the URL.
* Always present if type is 'post', not present for 'options'.
*
* - extraHeaders: Optional dictionary of extra request headers that were
* provided. These will not include the always-present request headers of
* MS-ASProtocolVersion and Content-Type.
*
* - sent data: If type is 'post', the ArrayBuffer provided to xhr.send().
*
* - response: In the case of a successful 'post', the WBXML Reader instance
* that will be passed to the callback for the method. If you use the
* reader, you are responsible for calling rewind() on it.
*/
this.onmessage = null;
}
exports.Connection = Connection;
Connection.prototype = {
/**
* Perform any callbacks added during the connection process.
*
* @param aError the error status (if any)
*/
_notifyConnected: function(aError) {
if (aError)
this.disconnect();
for (var iter in Iterator(this._connectionCallbacks)) {
var callback = iter[1];
callback.apply(callback, arguments);
}
this._connectionCallbacks = [];
},
/**
* Get the connection status.
*
* @return true iff we are fully connected to the server
*/
get connected() {
return this._connected;
},
/*
* Initialize the connection with a server and account credentials.
*
* @param aURL the ActiveSync URL to connect to
* @param aUsername the account's username
* @param aPassword the account's password
*/
open: function(aURL, aUsername, aPassword) {
// XXX: We add the default service path to the URL if it's not already
// there. This is a hack to work around the failings of Hotmail (and
// possibly other servers), which doesn't provide the service path in its
// URL. If it turns out this causes issues with other domains, remove it.
var servicePath = '/Microsoft-Server-ActiveSync';
this.baseUrl = aURL;
if (!this.baseUrl.endsWith(servicePath))
this.baseUrl += servicePath;
this._username = aUsername;
this._password = aPassword;
},
/**
* Connect to the server with this account by getting the OPTIONS from
* the server (and verifying the account's credentials).
*
* @param aCallback a callback taking an error status (if any) and the
* server's options.
*/
connect: function(aCallback) {
// If we're already connected, just run the callback and return.
if (this.connected) {
if (aCallback)
aCallback(null);
return;
}
// Otherwise, queue this callback up to fire when we do connect.
if (aCallback)
this._connectionCallbacks.push(aCallback);
// Don't do anything else if we're already trying to connect.
if (this._waitingForConnection)
return;
this._waitingForConnection = true;
this._connectionError = null;
this.getOptions((function(aError, aOptions) {
this._waitingForConnection = false;
this._connectionError = aError;
if (aError) {
console.error('Error connecting to ActiveSync:', aError);
return this._notifyConnected(aError, aOptions);
}
this._connected = true;
this.versions = aOptions.versions;
this.supportedCommands = aOptions.commands;
this.currentVersion = new Version(aOptions.versions.slice(-1)[0]);
return this._notifyConnected(null, aOptions);
}).bind(this));
},
/**
* Disconnect from the ActiveSync server, and reset the connection state.
* The server and credentials remain set however, so you can safely call
* connect() again immediately after.
*/
disconnect: function() {
if (this._waitingForConnection)
throw new Error("Can't disconnect while waiting for server response");
this._connected = false;
this.versions = [];
this.supportedCommands = [];
this.currentVersion = null;
},
/**
* Attempt to provision this account. XXX: Currently, this doesn't actually
* do anything, but it's useful as a test command for Gmail to ensure that
* the user entered their password correctly.
*
* @param aCallback a callback taking an error status (if any) and the
* WBXML response
*/
provision: function(aCallback) {
var pv = ASCP.Provision.Tags;
var w = new WBXML.Writer('1.3', 1, 'UTF-8');
w.stag(pv.Provision)
.etag();
this.postCommand(w, aCallback);
},
/**
* Get the options for the server associated with this account.
*
* @param aCallback a callback taking an error status (if any), and the
* resulting options.
*/
getOptions: function(aCallback) {
if (!aCallback) aCallback = nullCallback;
var conn = this;
var xhr = new XMLHttpRequest({mozSystem: true, mozAnon: true});
xhr.open('OPTIONS', this.baseUrl, true);
setAuthHeader(xhr, this._username, this._password);
xhr.setRequestHeader('User-Agent', USER_AGENT);
xhr.timeout = this.timeout;
xhr.upload.onprogress = xhr.upload.onload = function() {
xhr.timeout = 0;
};
xhr.onload = function() {
if (xhr.status < 200 || xhr.status >= 300) {
console.error('ActiveSync options request failed with response ' +
xhr.status);
if (conn.onmessage)
conn.onmessage('options', 'error', xhr, null, null, null, null);
aCallback(new HttpError(xhr.statusText, xhr.status));
return;
}
// These headers are comma-separated lists. Sometimes, people like to
// put spaces after the commas, so make sure we trim whitespace too.
var result = {
versions: xhr.getResponseHeader('MS-ASProtocolVersions')
.split(/\s*,\s*/),
commands: xhr.getResponseHeader('MS-ASProtocolCommands')
.split(/\s*,\s*/)
};
if (conn.onmessage)
conn.onmessage('options', 'ok', xhr, null, null, null, result);
aCallback(null, result);
};
xhr.ontimeout = xhr.onerror = function() {
var error = new Error('Error getting OPTIONS URL');
console.error(error);
if (conn.onmessage)
conn.onmessage('options', 'timeout', xhr, null, null, null, null);
aCallback(error);
};
// Set the response type to "text" so that we don't try to parse an empty
// body as XML.
xhr.responseType = 'text';
xhr.send();
},
/**
* Check if the server supports a particular command. Requires that we be
* connected to the server already.
*
* @param aCommand a string/tag representing the command type
* @return true iff the command is supported
*/
supportsCommand: function(aCommand) {
if (!this.connected)
throw new Error('Connection required to get command');
if (typeof aCommand === 'number')
aCommand = ASCP.__tagnames__[aCommand];
return this.supportedCommands.indexOf(aCommand) !== -1;
},
/**
* DEPRECATED. See postCommand() below.
*/
doCommand: function() {
console.warn('doCommand is deprecated. Use postCommand instead.');
this.postCommand.apply(this, arguments);
},
/**
* Send a WBXML command to the ActiveSync server and listen for the
* response.
*
* @param aCommand {WBXML.Writer|String|Number}
* The WBXML representing the command or a string/tag representing the
* command type for empty commands
* @param aCallback a callback to call when the server has responded; takes
* two arguments: an error status (if any) and the response as a
* WBXML reader. If the server returned an empty response, the
* response argument is null.
* @param aExtraParams (optional) an object containing any extra URL
* parameters that should be added to the end of the request URL
* @param aExtraHeaders (optional) an object containing any extra HTTP
* headers to send in the request
* @param aProgressCallback (optional) a callback to invoke with progress
* information, when available. Two arguments are provided: the
* number of bytes received so far, and the total number of bytes
* expected (when known, 0 if unknown).
*/
postCommand: function(aCommand, aCallback, aExtraParams, aExtraHeaders,
aProgressCallback) {
var contentType = 'application/vnd.ms-sync.wbxml';
if (typeof aCommand === 'string' || typeof aCommand === 'number') {
this.postData(aCommand, contentType, null, aCallback, aExtraParams,
aExtraHeaders);
}
// WBXML.Writer
else {
var commandName = ASCP.__tagnames__[aCommand.rootTag];
this.postData(
commandName, contentType,
aCommand.dataType === 'blob' ? aCommand.blob : aCommand.buffer,
aCallback, aExtraParams, aExtraHeaders, aProgressCallback);
}
},
/**
* Send arbitrary data to the ActiveSync server and listen for the response.
*
* @param aCommand a string (or WBXML tag) representing the command type
* @param aContentType the content type of the post data
* @param aData {ArrayBuffer|Blob} the data to be posted
* @param aCallback a callback to call when the server has responded; takes
* two arguments: an error status (if any) and the response as a
* WBXML reader. If the server returned an empty response, the
* response argument is null.
* @param aExtraParams (optional) an object containing any extra URL
* parameters that should be added to the end of the request URL
* @param aExtraHeaders (optional) an object containing any extra HTTP
* headers to send in the request
* @param aProgressCallback (optional) a callback to invoke with progress
* information, when available. Two arguments are provided: the
* number of bytes received so far, and the total number of bytes
* expected (when known, 0 if unknown).
*/
postData: function(aCommand, aContentType, aData, aCallback, aExtraParams,
aExtraHeaders, aProgressCallback) {
// Make sure our command name is a string.
if (typeof aCommand === 'number')
aCommand = ASCP.__tagnames__[aCommand];
if (!this.supportsCommand(aCommand)) {
var error = new Error("This server doesn't support the command " +
aCommand);
console.error(error);
aCallback(error);
return;
}
// Build the URL parameters.
var params = [
['Cmd', aCommand],
['User', this._username],
['DeviceId', this._deviceId],
['DeviceType', this._deviceType]
];
if (aExtraParams) {
for (var iter in Iterator(params)) {
var param = iter[1];
if (param[0] in aExtraParams)
throw new TypeError('reserved URL parameter found');
}
for (var kv in Iterator(aExtraParams))
params.push(kv);
}
var paramsStr = params.map(function(i) {
return encodeURIComponent(i[0]) + '=' + encodeURIComponent(i[1]);
}).join('&');
// Now it's time to make our request!
var xhr = new XMLHttpRequest({mozSystem: true, mozAnon: true});
xhr.open('POST', this.baseUrl + '?' + paramsStr, true);
setAuthHeader(xhr, this._username, this._password);
xhr.setRequestHeader('MS-ASProtocolVersion', this.currentVersion);
xhr.setRequestHeader('Content-Type', aContentType);
xhr.setRequestHeader('User-Agent', USER_AGENT);
// Add extra headers if we have any.
if (aExtraHeaders) {
for (var iter in Iterator(aExtraHeaders)) {
var key = iter[0], key = iter[1];
xhr.setRequestHeader(key, value);
}
}
xhr.timeout = this.timeout;
xhr.upload.onprogress = xhr.upload.onload = function() {
xhr.timeout = 0;
};
xhr.onprogress = function(event) {
if (aProgressCallback)
aProgressCallback(event.loaded, event.total);
};
var conn = this;
var parentArgs = arguments;
xhr.onload = function() {
// This status code is a proprietary Microsoft extension used to
// indicate a redirect, not to be confused with the draft-standard
// "Unavailable For Legal Reasons" status. More info available here:
// <http://msdn.microsoft.com/en-us/library/gg651019.aspx>
if (xhr.status === 451) {
conn.baseUrl = xhr.getResponseHeader('X-MS-Location');
if (conn.onmessage)
conn.onmessage(aCommand, 'redirect', xhr, params, aExtraHeaders,
aData, null);
conn.postData.apply(conn, parentArgs);
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
console.error('ActiveSync command ' + aCommand + ' failed with ' +
'response ' + xhr.status);
if (conn.onmessage)
conn.onmessage(aCommand, 'error', xhr, params, aExtraHeaders,
aData, null);
aCallback(new HttpError(xhr.statusText, xhr.status));
return;
}
var response = null;
if (xhr.response.byteLength > 0)
response = new WBXML.Reader(new Uint8Array(xhr.response), ASCP);
if (conn.onmessage)
conn.onmessage(aCommand, 'ok', xhr, params, aExtraHeaders,
aData, response);
aCallback(null, response);
};
xhr.ontimeout = xhr.onerror = function(evt) {
var error = new Error('Command URL ' + evt.type + ' for command ' +
aCommand + ' at baseUrl ' + this.baseUrl);
console.error(error);
if (conn.onmessage)
conn.onmessage(aCommand, evt.type, xhr, params, aExtraHeaders,
aData, null);
aCallback(error);
}.bind(this);
xhr.responseType = 'arraybuffer';
xhr.send(aData);
},
};
return exports;
}));