'use strict';
import ES6PromiseProvider from './../promises/ES6PromiseProvider.js';
import Utils from './../utils/Utils.js';
import Account from './../models/Account.js';
import Mailbox from './../models/Mailbox.js';
import MessageList from './../models/MessageList.js';
import SetResponse from './../models/SetResponse.js';
import Thread from './../models/Thread.js';
import Message from './../models/Message.js';
import OutboundMessage from './../models/OutboundMessage.js';
import CreateMessageAck from './../models/CreateMessageAck.js';
import MailboxRole from './../models/MailboxRole.js';
import AuthContinuation from './../models/AuthContinuation.js';
import AuthAccess from './../models/AuthAccess.js';
import Constants from '../utils/Constants.js';
import VacationResponse from './../models/VacationResponse';
import JmapError from '../errors/JmapError';
export default class Client {
/**
* The {@link Client} class is the main entry point for sending JMAP requests to a remote server.<br />
* It uses a fluent API so that it's easy to chain calls. JMAP requests are sent using one of the _getXXX_ methods
* that map to their equivalent in the JMAP specification. For instance, if you want to do a _getAccounts_ request,
* you'll use the {@link Client#getAccounts} method.
*
* @param transport {Transport} The {@link Transport} instance used to send HTTP requests.
* @param [promiseProvider={@link ES6PromiseProvider}] {PromiseProvider} The {@link PromiseProvider} implementation to use.
*/
constructor(transport, promiseProvider) {
Utils.assertRequiredParameterIsPresent(transport, 'transport');
this.promiseProvider = promiseProvider || new ES6PromiseProvider();
this.transport = transport;
this.transport.promiseProvider = this.promiseProvider;
}
/**
* Registers an authentication URL, that will be used as the endpoint to send authentication requests to the server.<br />
* <br />
* The URL will be exposed as the `authenticationUrl` property afterwards.
*
* @param url {String} The authentication url to use in JMAP requests.
*
* @return {Client} This Client instance.
*/
withAuthenticationUrl(url) {
this.authenticationUrl = url;
return this;
}
/**
* Registers an authentication token, obtained by an external mechanism from the target JMAP server.<br />
* This token will then be used as the `Authorization` header, as per {@link http://jmap.io/spec.html#authenticating-http-requests}.<br />
* <br />
* The token will be exposed as the `authToken` property afterwards.
*
* @param token {String} The authentication token to use in JMAP requests.
* @param [scheme] {String} The authentication scheme according to RFC 7235
*
* @return {Client} This Client instance.
*/
withAuthenticationToken(token, scheme) {
this.authToken = token;
this.authScheme = scheme;
return this;
}
/**
* Sets the API URL of the target JMAP server. All JMAP requests will be sent to this URL.<br />
* <br />
* The URL will be exposed as the `apiUrl` property afterwards.
*
* @param url {String} The API URL of the JMAP server.
*
* @return {Client} This Client instance.
*/
withAPIUrl(url) {
this.apiUrl = url;
return this;
}
/**
* Sets the download URL, i.e.: the URL used to download attachments to {@link Message}s.<br />
* <br />
* The URL will be exposed as the `downloadUrl` property afterwards.
*
* @param url {String} The download URL of the JMAP server.
*
* @return {Client} This Client instance.
*/
withDownloadUrl(url) {
this.downloadUrl = url;
return this;
}
/**
* Initializes the client with an {@link AuthAccess} model from an authentication response.<br />
* <br />
* The individual properties of the AuthAccess object will be copied into client properties.
*
* @param access {AuthAccess|Object} The response object from an authenticate process.
*
* @return {Client} This Client instance.
*/
withAuthAccess(access) {
Utils.assertRequiredParameterIsObject(access, 'access');
// create an instance of AuthAccess if plain object is given
if (!(access instanceof AuthAccess)) {
access = new AuthAccess(this, access);
}
this.authAccess = access;
this.authScheme = 'X-JMAP';
this.authToken = access.accessToken;
['username', 'signingId', 'signingKey', 'apiUrl', 'eventSourceUrl', 'uploadUrl', 'downloadUrl', 'serverCapabilities', 'mailCapabilities'].forEach((property) => {
this[property] = access[property];
});
return this;
}
/**
* Implement the 2-step JMAP authentication protocol.<br />
* This method abstract the two authentication steps:
*
* 1. query the JMAP server to get a continuation token
* 2. query the JMAP server with the continuation token (and password), to get the final accessToken.
*
* @param username {String} The username to authenticate with
* @param deviceName {String} A unique device name
* @param continuationCallback {Function} A function that takes an {@link AuthContinuation}
* object as argument, and should return a promise, that will eventually resolve with an
* object denoting the chosen authentication method and the optional password (if method == password).
*
* @return {Promise} A {@link Promise} that will eventually be resovled with a {@link AuthAccess} object
*/
authenticate(username, deviceName, continuationCallback) {
return this.transport
.post(this.authenticationUrl, this._defaultNonAuthenticatedHeaders(), {
username: username,
deviceName: deviceName,
clientName: Constants.CLIENT_NAME,
clientVersion: Constants.VERSION
})
.then(data => this._authenticateResponse(data, continuationCallback));
}
/**
* Sub-routine handling JMAP server responses on authentication requests
*
* Depending on the server's response, this either (recursively) executes the second
* authentication step by calling the provided continuationCallback or resolves on
* successfully completed authentication.
*
* @param data {Object} The JMAP response data
* @param continuationCallback {Function} The callback function initially passed to the authenticate() method
*
* @return {Promise} A {@link Promise} that will eventually be resovled with a {@link AuthAccess} object
*/
_authenticateResponse(data, continuationCallback) {
if (data.loginId && data.accessToken === undefined) {
// got an AuthContinuation response
var authContinuation = new AuthContinuation(data);
return continuationCallback(authContinuation)
.then(continueData => {
if (!authContinuation.supports(continueData.type)) {
throw new Error('The "' + continueData.type + '" authentication type is not supported by the server.');
}
let param = {
loginId: authContinuation.loginId,
type: continueData.type
};
if (continueData.value) {
param.value = continueData.value;
}
return this.transport
.post(this.authenticationUrl, this._defaultNonAuthenticatedHeaders(), param);
})
.then(resp => this._authenticateResponse(resp, continuationCallback));
} else if (data.accessToken && data.loginId === undefined) {
// got auth access response
return new AuthAccess(this, data);
} else {
// got unknown response data
throw new Error('Unexpected response on authorization request');
}
}
/**
* Implement the JMAP external authentication protocol.<br />
* This method abstract the two authentication steps:
*
* 1. query the JMAP server to get a continuation token
* 2. query the JMAP server with the continuation token, to get the final accessToken.
*
* <br />
* Between those two steps, a user provided function wil be called to handle the external
* authentication part.
* <br />
* This method returns a promise that will eventually be resovled with a {@link AuthAccess} object.
* It's the responsability of the caller to then set the AuthToken using for example:
* <br />
*
* client.withAuthenticationToken(authAccess.accessToken);
*
* @param username {String} The username of the user to authenticate.
* @param deviceName {String} A unique device name
* @param continuationCallback {Function} A function that takes an {@link AuthContinuation} object as argument, and should return a promise, that will resolve once the external authentication is complete.
*
* @return {Promise} A {@link Promise} that will eventually be resovled with a {@link AuthAccess} object
*/
authExternal(username, deviceName, continuationCallback) {
return this.authenticate(username, deviceName, function(authContinuation) {
// wrap the continuationCallback to resolve with method:'external'
return continuationCallback(authContinuation).then(() => ({ type: 'external' }));
});
}
/**
* Implement the JMAP password authentication protocol.<br />
* This method abstract the two authentication steps:
*
* 1. query the JMAP server to get a continuation token
* 2. query the JMAP server with the continuation token and the password, to get the final accessToken.
*
* @param username {String} The username of the user to authenticate
* @param password {String} The password of the user to authenticate
* @param deviceName {String} A unique device name
*
* @return {Promise} A {@link Promise} that will eventually be resovled with a {@link AuthAccess} object
*/
authPassword(username, password, deviceName) {
return this.authenticate(username, deviceName, function() {
return this.promiseProvider.newPromise(function(resolve, reject) {
resolve({ type: 'password', value: password });
});
}.bind(this));
}
/**
* Sends a _getAccounts_ JMAP request.
*
* @param [options=null] {Object} The options to the _getAccounts_ JMAP request.
*
* @return {Promise} A {@link Promise} that eventually resolves to an array of {@link Account} objects.
*
* @see http://jmap.io/spec.html#getaccounts
*/
getAccounts(options) {
// resolve with accounts list from AuthAccess
if (this.authAccess instanceof AuthAccess) {
return this.promiseProvider.newPromise(function(resolve, reject) {
let accounts = [];
// equivalent to Object.values()
for (let id in this.authAccess.accounts) {
if (this.authAccess.accounts.hasOwnProperty(id)) {
accounts.push(this.authAccess.accounts[id]);
}
}
resolve(accounts);
}.bind(this));
}
// fallback to deprecated getAccounts request
return this._jmapRequest('getAccounts', options);
}
/**
* Sends a _getMailboxes_ JMAP request.
*
* @param [options=null] {Object} The options to the _getMailboxes_ JMAP request.
*
* @return {Promise} A {@link Promise} that eventually resolves to an array of {@link Mailbox} objects.
*
* @see http://jmap.io/spec.html#getmailboxes
*/
getMailboxes(options) {
return this._jmapRequest('getMailboxes', options);
}
/**
* Sends a _setMailboxes_ JMAP request.
*
* @param [options=null] {Object} The options to the _setMailboxes_ JMAP request.
*
* @return {Promise} A {@link Promise} that eventually resolves to a {@link Mailbox} object.
*
* @see http://jmap.io/spec.html#setmailboxes
*/
setMailboxes(options) {
return this._jmapRequest('setMailboxes', options);
}
/**
* Creates a mailbox by sending a _setMailboxes_ JMAP request.<br />
*
* @param name {String} The name of the mailbox to create.
* @param [parentId=null] {String} The id of the parent of the mailbox to create.
*
* @return {Promise} A {@link Promise} that eventually resolves to a {@link Mailbox}.
*
* @see http://jmap.io/spec.html#creating-mailboxes
*/
createMailbox(name, parentId) {
Utils.assertRequiredParameterIsPresent(name, 'name');
var clientId = this._generateClientId();
return this.setMailboxes({
create: {
[clientId]: {
name: name,
parentId: parentId
}
}
}).then(response => {
var created = response.created[clientId];
if (!created) {
throw new Error('Failed to create mailbox, clientId: ' + clientId + ', message: ' + (response.notCreated[clientId] || 'none'));
}
return new Mailbox(this, created.id, created.name || name, created);
});
}
/**
* Updates properties of a {@link Mailbox}.<br />
* This will issue a {@link Client#setMailboxes} JMAP request under the hoods, passing the correct options.
*
* @param id {String} The id of the {@link Mailbox} to update.
* @param options {Object} The options of the target mailbox to be updated.
*
* @return {Promise} A {@link Promise} that eventually resolves to nothing if the mailbox was updated successfully.
*
* @see http://jmap.io/spec.html#updating-mailboxes
*/
updateMailbox(id, options) {
Utils.assertRequiredParameterIsPresent(id, 'id');
return this.setMailboxes({
update: {
[id]: options
}
}).then(response => {
if (response.updated.indexOf(id) < 0) {
throw new Error('Failed to update mailbox ' + id + ', the reason is: ' + response.notUpdated[id]);
}
});
}
/**
* Destroy the {@link Mailbox} related to the given _id_ on the server.<br />
* This will issue a {@link Client#destroyMailboxes} request under the hoods, passing _[id]_ option.
*
* @param id {String} The id of the mailbox to destroy.
*
* @return {Promise} A {@link Promise} that eventually resolves to nothing if the {@link Mailbox} was destroyed successfully.
*
*/
destroyMailbox(id) {
Utils.assertRequiredParameterIsPresent(id, 'id');
return this.destroyMailboxes([id]);
}
/**
* Destroy multiple {@link Mailbox}es specified to the given _ids_ on the server.<br />
* This will issue a {@link Client#setMailboxes} JMAP request under the hoods, passing the correct options.
*
* @param ids {String[]} An array IDs of the mailboxes to destroy. These IDs must be in the right order: Destroy X comes before destroy Y if X is a descendent of Y.
*
* @return {Promise} A {@link Promise} that eventually resolves to nothing if all {@link Mailbox}es were destroyed successfully. Otherwise, it rejects error message of the first `notDestroyed` mailbox.
*
* @see http://jmap.io/spec.html#destroying-mailboxes
*/
destroyMailboxes(ids) {
Utils.assertRequiredParameterIsArrayWithMinimumLength(ids, 'ids', 1);
return this.setMailboxes({
destroy: ids
}).then(response => {
let notDestroyedIds = Object.keys(response.notDestroyed);
if (notDestroyedIds.length > 0) {
// take the first one for incrementally debugging
let setError = response.notDestroyed[notDestroyedIds[0]];
let reason = setError.type + ' (' + setError.description + ')';
throw new Error('Failed to destroy ' + notDestroyedIds[0] + ', the reason is: ' + reason);
}
});
}
/**
* Finds a {@link Mailbox} with the given role.<br />
* This will issue a _getMailboxes_ JMAP request and search for the mailbox in the returned list.
*
* @param role {MailboxRole|String} The desired mailbox role.
* @param [options=null] {Object} The options to the implicit _getMailboxes_ JMAP request.
*
* @return {Promise} A {@link Promise} that eventually resolves to the {@link Mailbox} if found.
*
* @see MailboxRole
*/
getMailboxWithRole(role, options) {
if (!(role instanceof MailboxRole)) {
role = MailboxRole.fromRole(role);
}
if (role === MailboxRole.UNKNOWN) {
throw new Error('A valid role is required to find a mailbox by role');
}
return this._jmapRequest('getMailboxes', options)
.then((mailboxes) => {
for (let i = 0; i < mailboxes.length; i++) {
if (mailboxes[i].role === role) {
return mailboxes[i];
}
}
throw new Error('Cannot find a mailbox with role ' + role.value);
});
}
/**
* Sends a _getMessageList_ JMAP request.
*
* @param [options=null] {Object} The options to the _getMessageList_ JMAP request.
*
* @return {Promise} A {@link Promise} that eventually resolves to a {@link MessageList} object.
*
* @see http://jmap.io/spec.html#getmessagelist
*/
getMessageList(options) {
return this._jmapRequest('getMessageList', options);
}
/**
* Sends a _getThreads_ JMAP request.
*
* @param [options=null] {Object} The options to the _getThreads_ JMAP request.
*
* @return {Promise} A {@link Promise} that eventually resolves to an array of {@link Thread} objects.
*
* @see http://jmap.io/spec.html#getthreads
*/
getThreads(options) {
return this._jmapRequest('getThreads', options);
}
/**
* Sends a _getMessages_ JMAP request.
*
* @param [options=null] {Object} The options to the _getMessages_ JMAP request.
*
* @return {Promise} A {@link Promise} that eventually resolves to an array of {@link Message} objects.
*
* @see http://jmap.io/spec.html#getmessages
*/
getMessages(options) {
return this._jmapRequest('getMessages', options);
}
/**
* Sends a _setMessages_ JMAP request.
*
* @param [options=null] {Object} The options to the _setMessages_ JMAP request.
*
* @return {Promise} A {@link Promise} that eventually resolves to a {@link SetResponse} object.
*
* @see http://jmap.io/spec.html#setmessages
*/
setMessages(options) {
return this._jmapRequest('setMessages', options);
}
/**
* Updates properties of a {@link Message}.<br />
* This will issue a {@link Client#setMessages} JMAP request under the hoods, passing the correct options.
*
* @param id {String} The id of the {@link Message} to update.
* @param options {Object} The options of the target message to be updated.
* @param options.mailboxIds {String[]} The identifiers of the new mailboxes for the message.
* @param options.isFlagged {Boolean} This corresponds whether the message is flagged or not
* @param options.isUnread {Boolean} This corresponds whether the message has been yet read or not
* @param options.isAnswered {Boolean} This corresponds whether the message has been yet replied or not
*
* @return {Promise} A {@link Promise} that eventually resolves to nothing if the message was updated successfully.
*
* @see http://jmap.io/spec.html#updating-messages
*/
updateMessage(id, options) {
Utils.assertRequiredParameterIsPresent(id, 'id');
Utils.assertRequiredParameterIsObject(options, 'options');
return this.setMessages({
update: {
[id]: options
}
}).then(response => {
if (response.updated.indexOf(id) < 0) {
throw new Error('Failed to update message ' + id + ', the reason is: ' + (response.notUpdated[id] || 'missing'));
}
});
}
/**
* Destroy the {@link Message} related to the given _id_ on the server.<br />
* This will issue a {@link Client#setMessages} JMAP request under the hoods, passing the correct options.
*
* @param id {String} The id of the object to destroy.
*
* @return {Promise} A {@link Promise} that eventually resolves to nothing if the {@link Message} was destroyed successfully.
*
* @see Client#setMessages
*/
destroyMessage(id) {
return this.destroyMessages(id && [id]).then(response => {
if (response.destroyed.indexOf(id) < 0) {
throw new Error('Failed to destroy ' + id + ', the reason is: ' + (response.notDestroyed[id] || 'missing'));
}
});
}
/**
* Destroy several {@link Message}s at once.<br />
* This will issue a {@link Client#setMessages} JMAP request under the hoods, passing the correct options.
*
* @param ids {String[]} The list of ids of the messages to destroy.
*
* @return {Promise} A {@link Promise} that resolves to a {@link SetResponse}, containing the result of the operation.
*
* @see Client#setMessages
*/
destroyMessages(ids) {
Utils.assertRequiredParameterIsArrayWithMinimumLength(ids, 'ids', 1);
return this.setMessages({ destroy: ids });
}
/**
* Save a message as draft by sending a _setMessages_ JMAP request.<br />
* The _mailboxIds_ and _isDraft_ properties of the given _message_ will be overridden by this method.
*
* @param message {OutboundMessage} The message to save.
*
* @return {Promise} A {@link Promise} that eventually resolves to a {@link CreateMessageAck}.
*
* @see http://jmap.io/spec.html#saving-a-draft
*/
saveAsDraft(message) {
return this._createMessage(message, MailboxRole.DRAFTS);
}
/**
* Sends a message by issuing a _setMessages_ JMAP request.<br />
* The _mailboxIds_ and _isDraft_ properties of the given _message_ will be overridden by this method.
*
* @param message {OutboundMessage} The message to send.
* @param outbox {Mailbox} The {@link Mailbox} with role='outbox', if already available
*
* @return {Promise} A {@link Promise} that eventually resolves to a {@link CreateMessageAck}.
*
* @see http://jmap.io/spec.html#sending-messages
*/
send(message, outbox) {
return this._createMessage(message, MailboxRole.OUTBOX, outbox);
}
/**
* Moves a {@link Message} to a different set of mailboxes.<br />
* This will issue a {@link Client#setMessages} JMAP request under the hoods, passing the correct options.
*
* @param id {String} The id of the {@link Message} to move.
* @param mailboxIds {String[]} The identifiers of the target mailboxes for the message.
*
* @return {Promise} A {@link Promise} that eventually resolves to nothing if the message was moved successfully.
*
* @see Client#setMessages
*/
moveMessage(id, mailboxIds) {
Utils.assertRequiredParameterIsPresent(id, 'id');
Utils.assertRequiredParameterIsArrayWithMinimumLength(mailboxIds, 'mailboxIds', 1);
return this.updateMessage(id, { mailboxIds: mailboxIds });
}
/**
* Gets the singleton {@link VacationResponse} instance for a given account.<br />
* This will send a `getVacationResponse` request to the JMAP backend.
*
* @param [options] {Object} The options to the underlying `getVacationResponse` JMAP request.
* @param [options.accountId=null] {String} The account ID to get the vacation response for. If `null`, the primary account is used.
*
* @returns {Promise} A {@link Promise} that eventually resolves to the {@link VacationResponse} instance.
*/
getVacationResponse(options) {
return this._jmapRequest('getVacationResponse', options).then(list => list[0]);
}
/**
* Sets the singleton {@link VacationResponse} instance for a given account.<br />
* This will send a `setVacationResponse` request to the JMAP backend.
*
* @param vacationResponse {VacationResponse} The {@link VacationResponse} instance to set
* @param [options] {Object} The options to the underlying `setVacationResponse` JMAP request.
* @param [options.accountId=null] {String} The account ID to set the vacation response for. If `null`, the primary account is used.
*
* @returns {Promise} A {@link Promise} that eventually resolves to nothing upon success.
*/
setVacationResponse(vacationResponse, options) {
Utils.assertRequiredParameterHasType(vacationResponse, 'vacationResponse', VacationResponse);
return this._jmapRequest('setVacationResponse', {
accountId: options && options.accountId,
update: {
[VacationResponse.ID]: vacationResponse.toJSONObject()
}
}).then(response => {
if (response.updated.indexOf(VacationResponse.ID) < 0) {
throw new Error('Failed to set vacation response. Error: ' + (response.notUpdated[VacationResponse.ID] || 'none'));
}
});
}
_createMessage(message, role, mailbox) {
Utils.assertRequiredParameterHasType(message, 'message', OutboundMessage);
let clientId = this._generateClientId(),
doSetMessages = mailbox => {
message.mailboxIds = [mailbox.id];
message.isDraft = MailboxRole.DRAFTS.value === role.value ? true : null;
return this.setMessages({
create: {
[clientId]: message.toJSONObject()
}
}).then(response => {
if (!response.created[clientId]) {
throw new Error('Failed to store message with clientId ' + clientId + '. Error: ' + (response.notCreated[clientId] || 'none'));
}
return new CreateMessageAck(this, response.created[clientId]);
});
};
return mailbox ? doSetMessages(mailbox) : this.getMailboxWithRole(role).then(doSetMessages);
}
_generateClientId() {
return Date.now();
}
_defaultNonAuthenticatedHeaders() {
return {
Accept: 'application/json; charset=UTF-8',
'Content-Type': 'application/json; charset=UTF-8'
};
}
_defaultHeaders() {
return {
Accept: 'application/json; charset=UTF-8',
'Content-Type': 'application/json; charset=UTF-8',
Authorization: (this.authScheme ? this.authScheme + ' ' : '') + this.authToken
};
}
_jmapRequest(method, options) {
return this.transport
.post(this.apiUrl, this._defaultHeaders(), [[method, options || {}, '#0']])
.then(data => Utils.assertValidJMAPResponse(method, data))
.then(data => data.map(response => this._handleResponse(response, method)))
.then(responses => responses.length > 1 ? responses : responses[0]);
}
_handleResponse(response, method) {
let name = response[0],
fn = this[`_handle${Utils.capitalize(name)}Response`];
// This will return the "raw" data if the command is unknown to the client
return fn ? fn.bind(this)(response, method) : response[1];
}
_handleErrorResponse(response, method) {
throw new JmapError(response[1], method);
}
_handleListResponse(response, Model, filter) {
return Utils._jsonArrayToModelList(this, Model, response[1].list, filter);
}
_handleSetResponse(response) {
return SetResponse.fromJSONObject(this, response[1]);
}
_handleAccountsResponse(response) {
return this._handleListResponse(response, Account);
}
_handleThreadsResponse(response) {
return this._handleListResponse(response, Thread);
}
_handleMessagesResponse(response) {
return this._handleListResponse(response, Message, function(message) {
try {
return Utils.assertRequiredParameterIsArrayWithMinimumLength(message.mailboxIds, 'mailboxIds', 1);
} catch (err) {
return false;
}
});
}
_handleMailboxesResponse(response) {
return this._handleListResponse(response, Mailbox);
}
_handleMailboxesSetResponse(response) {
return this._handleSetResponse(response);
}
_handleMessageListResponse(response) {
return MessageList.fromJSONObject(this, response[1]);
}
_handleMessagesSetResponse(response) {
return this._handleSetResponse(response);
}
_handleVacationResponseResponse(response) {
return this._handleListResponse(response, VacationResponse);
}
_handleVacationResponseSetResponse(response) {
return this._handleSetResponse(response);
}
}