com.googlecode.jdeltasync.DeltaSyncClient.java Source code

Java tutorial

Introduction

Here is the source code for com.googlecode.jdeltasync.DeltaSyncClient.java

Source

/*
 * Copyright (c) 2011, the JDeltaSync project. All Rights Reserved.
 *
 * 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.
 */
package com.googlecode.jdeltasync;

import com.googlecode.jdeltasync.hu01.HU01DecompressorOutputStream;
import com.googlecode.jdeltasync.hu01.HU01Exception;
import com.googlecode.jdeltasync.message.Clazz;
import com.googlecode.jdeltasync.message.Command;
import com.googlecode.jdeltasync.message.FolderAddCommand;
import com.googlecode.jdeltasync.message.FolderChangeCommand;
import com.googlecode.jdeltasync.message.FolderDeleteCommand;
import com.googlecode.jdeltasync.message.MessageAddCommand;
import com.googlecode.jdeltasync.message.MessageChangeCommand;
import com.googlecode.jdeltasync.message.MessageDeleteCommand;
import com.googlecode.jdeltasync.message.SyncRequest;
import com.googlecode.jdeltasync.message.SyncResponse;
import com.sun.org.apache.xerces.internal.dom.DeferredNode;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.SortedMap;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.regex.Pattern;
import org.apache.commons.codec.binary.Base64OutputStream;
import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.client.utils.URIUtils;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.client.RedirectLocations;
import org.apache.http.impl.conn.SchemeRegistryFactory;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpCoreContext;
import org.apache.james.mime4j.MimeException;
import org.apache.james.mime4j.descriptor.BodyDescriptor;
import org.apache.james.mime4j.message.SimpleContentHandler;
import org.apache.james.mime4j.parser.MimeStreamParser;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

/**
 * Main class used to communicate with the Windows Live Hotmail service using
 * Microsoft's proprietary DeltaSync protocol. {@link #login(String, String)}
 * has to be called to obtain a {@link IDeltaSyncSession} which can then be used
 * to query for the folders and messages on the server and to delete messages.
 */
public class DeltaSyncClient implements IDeltaSyncClient, ILegacyDeltaSyncClient {

    private static final String LOGIN_BASE_URI = "https://login.live.com/RST2.srf";
    private static final String LOGIN_USER_AGENT = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; IDCRL 5.000.819.1; IDCRL-cfg 6.0.11409.0; App wlmail.exe, 14.0.8117.416, {47A6D4CF-5EB0-4B0E-9138-1B3F2DD40981})";
    private static final String DS_USER_AGENT = "WindowsLiveMail/1.0";
    private static final String DS_BASE_URI = "http://mail.services.live.com";
    private static final byte[] LINE_SEPARATOR;

    static {
        try {
            LINE_SEPARATOR = System.getProperty("line.separator").getBytes("ASCII");
        } catch (UnsupportedEncodingException e) {
            throw new Error(e);
        }
    }

    private final HttpClient httpClient;
    private RequestConfig rcConfig = RequestConfig.DEFAULT;

    /**
     * Creates a new {@link DeltaSyncClient} using a
     * {@link ThreadSafeClientConnManager} with the default settings.
    *
    * @deprecated ThreadSafeClientConnManager has been deprecated in HttpClient. Use {@link #DeltaSyncClient(org.apache.http.client.HttpClient)}
     */
    @Deprecated
    public DeltaSyncClient() {
        this(new ThreadSafeClientConnManager(SchemeRegistryFactory.createDefault()));
    }

    /**
     * Creates a new {@link DeltaSyncClient} using the specified
     * {@link ClientConnectionManager}.
     *
     * @param connectionManager the {@link ClientConnectionManager}.
     * @deprecated ThreadSafeClientConnManager has been deprecated in HttpClient. Use {@link #DeltaSyncClient(org.apache.http.client.HttpClient)}
     */
    @Deprecated
    public DeltaSyncClient(ClientConnectionManager connectionManager) {
        this.httpClient = new DefaultHttpClient(connectionManager);
        this.setConnectionTimeout(5 * 1000);
        this.setSoTimeout(60 * 1000);
    }

    public DeltaSyncClient(HttpClient httpClient) {
        if (httpClient == null) {
            throw new IllegalArgumentException("Client must not be NULL.");
        } else {
            this.httpClient = httpClient;
        }
    }

    /**
     * Returns the {@link ClientConnectionManager} in use.
     *
     * @return the {@link ClientConnectionManager}.
     */
    @Deprecated
    @Override
    public ClientConnectionManager getConnectionManager() {
        return httpClient.getConnectionManager();
    }

    /**
     * Sets the connection timeout of the {@link HttpClient} instance. See
     * {@link CoreConnectionPNames#CONNECTION_TIMEOUT}.
     *
     * @param timeout the timeout.
     */
    @Override
    public void setConnectionTimeout(int timeout) {
        this.rcConfig = RequestConfig.copy(this.rcConfig).setConnectTimeout(timeout).build();
    }

    /**
     * Sets the socket timeout (SO_TIMEOUT) of the {@link HttpClient} instance. See
     * {@link CoreConnectionPNames#SO_TIMEOUT}.
     *
     * @param timeout the timeout.
     */
    @Override
    public void setSoTimeout(int timeout) {
        this.rcConfig = RequestConfig.copy(this.rcConfig).setSocketTimeout(timeout).build();
    }

    /**
     * Logs in using the specified username and password. Returns a
     * {@link IDeltaSyncSession} object on successful authentication.
     *
     * @param username the username.
     * @param password the password.
     * @return the session.
     * @throws AuthenticationException if authentication fails.
     * @throws DeltaSyncException on errors returned by the server.
     * @throws IOException on communication errors.
     */
    @Override
    public IDeltaSyncSession login(String username, String password)
            throws AuthenticationException, DeltaSyncException, IOException {

        if (username == null) {
            throw new NullPointerException("username");
        }
        if (password == null) {
            throw new NullPointerException("password");
        }

        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
        format.setTimeZone(TimeZone.getTimeZone("UTC"));

        Date created = new Date();
        Date expires = new Date(created.getTime() + 5 * 60 * 1000);

        Document request = XmlUtil.parse(getClass().getResourceAsStream("login-request.xml"));
        Element elSecurity = XmlUtil.getElement(request, "/s:Envelope/s:Header/wsse:Security");
        XmlUtil.setTextContent(elSecurity, "wsse:UsernameToken/wsse:Username", username);
        XmlUtil.setTextContent(elSecurity, "wsse:UsernameToken/wsse:Password", password);
        XmlUtil.setTextContent(elSecurity, "wsu:Timestamp/wsu:Created", format.format(created));
        XmlUtil.setTextContent(elSecurity, "wsu:Timestamp/wsu:Expires", format.format(expires));

        IDeltaSyncSession session = new DeltaSyncSession(username, password);

        if (session.getLogger().isDebugEnabled()) {
            session.getLogger().debug("Sending login request: {}",
                    XmlUtil.toString(request, false).replaceAll(Pattern.quote(password), "******"));
        }

        Document response = post(session, LOGIN_BASE_URI, LOGIN_USER_AGENT, "application/soap+xml", request,
                new UriCapturingResponseHandler<Document>() {

                    public Document handle(URI uri, HttpResponse response) throws DeltaSyncException, IOException {
                        return XmlUtil.parse(response.getEntity().getContent());
                    }
                });

        if (session.getLogger().isDebugEnabled()) {
            session.getLogger().debug("Received login response: {}", XmlUtil.toString(response, false));
        }
        if (XmlUtil.hasElement(response, "/s:Envelope/s:Body/s:Fault")) {
            throw new AuthenticationException(
                    XmlUtil.getTextContent(response, "/s:Envelope/s:Body/s:Fault/s:Reason/s:Text"));
        }

        String ticket = XmlUtil.getTextContent(response,
                "/s:Envelope/s:Body/wst:RequestSecurityTokenResponseCollection/"
                        + "wst:RequestSecurityTokenResponse/wst:RequestedSecurityToken/wsse:BinarySecurityToken");
        if (ticket == null) {
            String flowUrl = XmlUtil.getTextContent(response,
                    "/s:Envelope/s:Body/wst:RequestSecurityTokenResponseCollection/"
                            + "wst:RequestSecurityTokenResponse/psf:pp/psf:flowurl");
            String requestStatus = XmlUtil.getTextContent(response,
                    "/s:Envelope/s:Body/wst:RequestSecurityTokenResponseCollection/"
                            + "wst:RequestSecurityTokenResponse/psf:pp/psf:reqstatus");
            String errorStatus = XmlUtil.getTextContent(response,
                    "/s:Envelope/s:Body/wst:RequestSecurityTokenResponseCollection/"
                            + "wst:RequestSecurityTokenResponse/psf:pp/psf:errorstatus");
            if (flowUrl != null || requestStatus != null || errorStatus != null) {
                throw new AuthenticationException(flowUrl, requestStatus, errorStatus);
            }
            throw new AuthenticationException("Uknown authentication failure");
        }

        session.setTicket(ticket);
        session.setBaseUri(DS_BASE_URI);

        return session;
    }

    /**
     * Renews the specified session and returns a new session. The old session
     * object must be discarded. This should be called when a {@link SessionExpiredException}
     * has been thrown indicating that a session has expired.
     *
     * @param session the old session.
     * @return the new session.
     * @throws AuthenticationException if authentication fails.
     * @throws DeltaSyncException on errors returned by the server.
     * @throws IOException on communication errors.
     */
    @Override
    public IDeltaSyncSession renew(IDeltaSyncSession session)
            throws AuthenticationException, DeltaSyncException, IOException {

        return login(session.getUsername(), session.getPassword());
    }

    /**
     * Downloads the HU01 compressed content of the message with the specified
     * id and writes it to the specified {@link OutputStream}.
     *
     * @param session the session.
     * @param messageId the id of the message to download.
     * @param out the stream to write the HU01 compressed message content to.
     * @throws SessionExpiredException if the session has expired.
     * @throws DeltaSyncException on errors returned by the server.
     * @throws IOException on communication errors.
     */
    @Override
    public void downloadRawMessageContent(IDeltaSyncSession session, String messageId, OutputStream out)
            throws DeltaSyncException, IOException {

        downloadMessageContent(session, messageId, out, true);
    }

    /**
     * Downloads the content of the message with the specified id and writes it
     * to the specified {@link OutputStream}.
     *
     * @param session the session.
     * @param messageId the id of the message to download.
     * @param out the stream to write the message content to.
     * @throws SessionExpiredException if the session has expired.
     * @throws DeltaSyncException on errors returned by the server.
     * @throws IOException on communication errors.
     */
    @Override
    public void downloadMessageContent(IDeltaSyncSession session, String messageId, OutputStream out)
            throws DeltaSyncException, IOException {

        downloadMessageContent(session, messageId, out, false);
    }

    private void downloadMessageContent(final IDeltaSyncSession session, final String messageId,
            final OutputStream output, final boolean raw) throws DeltaSyncException, IOException {

        String request = "<ItemOperations xmlns=\"ItemOperations:\" xmlns:A=\"HMMAIL:\">" + "<Fetch>"
                + "<Class>Email</Class>" + "<A:ServerId>" + messageId + "</A:ServerId>"
                + "<A:Compression>hm-compression</A:Compression>"
                + "<A:ResponseContentType>mtom</A:ResponseContentType>" + "</Fetch>" + "</ItemOperations>";

        Document response = itemOperations(session, request, new UriCapturingResponseHandler<Document>() {
            public Document handle(URI uri, HttpResponse response) throws DeltaSyncException, IOException {

                session.setBaseUri(uri.getScheme() + "://" + uri.getHost());

                Header contentType = response.getFirstHeader("Content-Type");
                if (contentType == null || !contentType.getValue().equals("application/xop+xml")) {
                    if (contentType != null && contentType.getValue().equals("text/xml")) {
                        // If we receive a text/xml response it means an error has occurred
                        return XmlUtil.parse(response.getEntity().getContent());
                    }
                    throw new DeltaSyncException("Unexpected Content-Type received: " + contentType);
                }

                final Object[] result = new Object[1];
                MimeStreamParser parser = new MimeStreamParser();
                parser.setContentHandler(new SimpleContentHandler() {

                    @Override
                    public void headers(org.apache.james.mime4j.message.Header header) {
                    }

                    @Override
                    public void bodyDecoded(BodyDescriptor bd, InputStream is) throws IOException {

                        if ("application/xop+xml".equals(bd.getMimeType())) {
                            try {
                                result[0] = XmlUtil.parse(is);
                            } catch (XmlException e) {
                                result[0] = e;
                            }
                        } else if ("application/octet-stream".equals(bd.getMimeType())) {
                            OutputStream out = output;
                            if (!raw) {
                                out = new HU01DecompressorOutputStream(output);
                            }
                            byte[] buffer = new byte[4096];
                            int n;
                            while ((n = is.read(buffer)) != -1) {
                                out.write(buffer, 0, n);
                            }
                            out.flush();
                        }
                    }
                });

                try {
                    parser.parse(response.getEntity().getContent());
                } catch (MimeException e) {
                    throw new DeltaSyncException("Failed to parse multipart xop+xml response", e);
                } catch (IOException e) {
                    if (e.getCause() != null && (e.getCause() instanceof HU01Exception)) {
                        session.getLogger().error("HU01 decompression failed: ", e.getCause());
                        session.getLogger().error("Dumping HU01 stream as BASE64 for message {}", messageId);
                        session.getLogger().error("Please submit the BASE64 encoded message content");
                        session.getLogger().error("and the plain text message content to the JDeltaSync");
                        session.getLogger().error("issue tracker. The plain text message content can");
                        session.getLogger().error("be retrieved by clicking \"View message source\" in");
                        session.getLogger().error("the Hotmail web UI.");
                        ByteArrayOutputStream baos = new ByteArrayOutputStream(8192);
                        Base64OutputStream base64Out = new Base64OutputStream(baos, true, 72, LINE_SEPARATOR);
                        try {
                            downloadRawMessageContent(session, messageId, base64Out);
                            base64Out.close();
                            session.getLogger().error(new String(baos.toByteArray(), "ASCII"));
                        } catch (Throwable t) {
                            session.getLogger().error("Failed to dump HU01 stream", t);
                        }
                        throw (HU01Exception) e.getCause();
                    }
                    throw e;
                }

                if (result[0] instanceof DeltaSyncException) {
                    throw (DeltaSyncException) result[0];
                }
                return (Document) result[0];
            }
        });

        if (session.getLogger().isDebugEnabled()) {
            session.getLogger().debug("Received ItemOperations response: {}", XmlUtil.toString(response, false));
        }

        checkStatus(response);
        // No general error in the response. Check for a specific <Fetch> error.
        Element elStatus = XmlUtil.getElement(response,
                "/itemop:ItemOperations/itemop:Responses/itemop:Fetch/itemop:Status");
        if (elStatus == null) {
            throw new DeltaSyncException(
                    "No <Status> element found in <Fetch> response: " + XmlUtil.toString(response, true));
        }
        int code = Integer.parseInt(elStatus.getTextContent().trim());
        if (code == 4403) {
            throw new NoSuchMessageException(messageId);
        } else if (code != 1) {
            throw new UnrecognizedErrorCodeException(code,
                    "Unrecognized error code in response for <Fetch> request. Response was: "
                            + XmlUtil.toString(response, true));
        }
    }

    private void addCommand(SortedMap<Integer, List<Command>> commandMap, Element element, Command command) {
        int index;
        if (element instanceof DeferredNode) {
            index = ((DeferredNode) element).getNodeIndex();
        } else {
            index = -1;
        }
        List<Command> commandsForIndex = commandMap.get(index);
        if (commandsForIndex == null) {
            commandsForIndex = new ArrayList<Command>();
            commandMap.put(index, commandsForIndex);
        }
        commandsForIndex.add(command);
    }

    @Override
    public SyncResponse sync(IDeltaSyncSession session, SyncRequest syncRequest)
            throws DeltaSyncException, IOException {

        StringBuilder request = new StringBuilder("<Sync xmlns=\"AirSync:\"><Collections>");
        for (SyncRequest.Collection collection : syncRequest.getCollections()) {
            request.append("<Collection>");
            request.append("<Class>").append(collection.getClazz().getSyncName()).append("</Class>");
            if (collection.getCollectionId() != null) {
                request.append("<CollectionId>").append(collection.getCollectionId()).append("</CollectionId>");
            }
            request.append("<SyncKey>").append(collection.getSyncKey()).append("</SyncKey>");
            if (collection.isGetChanges()) {
                request.append("<GetChanges/>");
            }
            if (collection.getWindowSize() > 0) {
                request.append("<WindowSize>").append(collection.getWindowSize()).append("</WindowSize>");
            }
            if (!collection.getCommands().isEmpty()) {
                request.append("<Commands>");
                for (Command command : collection.getCommands()) {
                    request.append("<Delete>").append("<ServerId>");
                    switch (collection.getClazz()) {
                    case Email:
                        if (command instanceof MessageDeleteCommand) {
                            request.append(((MessageDeleteCommand) command).getId());
                        }
                        break;
                    case Folder:
                        if (command instanceof FolderDeleteCommand) {
                            request.append(((FolderDeleteCommand) command).getId());
                        }
                        break;
                    }
                    request.append("</ServerId>").append("</Delete>");
                }
                request.append("</Commands>");
            }
            request.append("</Collection>");
        }
        request.append("</Collections></Sync>");

        Document response = sync(session, request.toString());

        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
        format.setTimeZone(TimeZone.getTimeZone("UTC"));

        List<SyncResponse.Collection> collections = new ArrayList<SyncResponse.Collection>();
        for (Element elCollection : XmlUtil.getElements(response, "//airsync:Collection")) {
            String syncKey = XmlUtil.getTextContent(elCollection, "airsync:SyncKey");
            Clazz clazz = Clazz.valueOf(XmlUtil.getTextContent(elCollection, "airsync:Class"));
            int status = Integer.parseInt(XmlUtil.getTextContent(elCollection, "airsync:Status"));
            boolean moreAvailable = XmlUtil.hasElement(elCollection, "airsync:MoreAvailable");
            SortedMap<Integer, List<Command>> commandMap = new TreeMap<Integer, List<Command>>();

            switch (clazz) {
            case Email:
                for (Element elAdd : XmlUtil.getElements(elCollection, "airsync:Commands/airsync:Add")) {
                    try {
                        String id = XmlUtil.getTextContent(elAdd, "airsync:ServerId");
                        String folderId = XmlUtil.getTextContent(elAdd, "hmmail:FolderId");
                        Element elAppData = XmlUtil.getElement(elAdd, "airsync:ApplicationData");
                        Element elFlag = XmlUtil.getElement(elAppData, "hmmail:Flag");
                        boolean hasFlag = Integer.parseInt(XmlUtil.getTextContent(elFlag, "hmmail:State")) != 0;
                        long size = Long.parseLong(XmlUtil.getTextContent(elAppData, "hmmail:Size"));
                        boolean read = Integer.parseInt(XmlUtil.getTextContent(elAppData, "email:Read")) == 1;
                        boolean hasAttachments = Integer
                                .parseInt(XmlUtil.getTextContent(elAppData, "hmmail:HasAttachments")) == 1;
                        Date dateReceived = format.parse(XmlUtil.getTextContent(elAppData, "email:DateReceived"));
                        String subject = XmlUtil.getTextContent(elAppData, "email:Subject");
                        String from = XmlUtil.getTextContent(elAppData, "email:From");
                        addCommand(commandMap, elAdd, new MessageAddCommand(id, folderId, dateReceived, size, read,
                                subject, from, hasAttachments, hasFlag));
                    } catch (ParseException e) {
                        throw new DeltaSyncException(e);
                    }
                }
                for (Element elChange : XmlUtil.getElements(elCollection, "airsync:Commands/airsync:Change")) {
                    try {
                        String id = XmlUtil.getTextContent(elChange, "airsync:ServerId");
                        String folderId = XmlUtil.getTextContent(elChange, "hmmail:FolderId");
                        Element elAppData = XmlUtil.getElement(elChange, "airsync:ApplicationData");
                        Element elFlag = XmlUtil.getElement(elAppData, "hmmail:Flag");
                        boolean hasFlag = Integer.parseInt(XmlUtil.getTextContent(elFlag, "hmmail:State")) != 0;
                        long size = Long.parseLong(XmlUtil.getTextContent(elAppData, "hmmail:Size"));
                        boolean read = Integer.parseInt(XmlUtil.getTextContent(elAppData, "email:Read")) == 1;
                        boolean hasAttachments = Integer
                                .parseInt(XmlUtil.getTextContent(elAppData, "hmmail:HasAttachments")) == 1;
                        Date dateReceived = format.parse(XmlUtil.getTextContent(elAppData, "email:DateReceived"));
                        String subject = XmlUtil.getTextContent(elAppData, "email:Subject");
                        String from = XmlUtil.getTextContent(elAppData, "email:From");
                        addCommand(commandMap, elChange, new MessageChangeCommand(id, folderId, dateReceived, size,
                                read, subject, from, hasAttachments, hasFlag));
                    } catch (ParseException e) {
                        throw new DeltaSyncException(e);
                    }
                }
                for (Element elDelete : XmlUtil.getElements(elCollection, "airsync:Commands/airsync:Delete")) {
                    String id = XmlUtil.getTextContent(elDelete, "airsync:ServerId");
                    addCommand(commandMap, elDelete, new MessageDeleteCommand(id));
                }
                break;
            case Folder:
                for (Element elAdd : XmlUtil.getElements(elCollection, "airsync:Commands/airsync:Add")) {
                    String id = XmlUtil.getTextContent(elAdd, "airsync:ServerId");
                    String displayName = XmlUtil.getTextContent(elAdd,
                            "airsync:ApplicationData/hmfolder:DisplayName");
                    String parentID = XmlUtil.getTextContent(elAdd, "airsync:ApplicationData/hmfolder:ParentId");
                    addCommand(commandMap, elAdd, new FolderAddCommand(id, displayName, parentID));

                }
                for (Element elChange : XmlUtil.getElements(elCollection, "airsync:Commands/airsync:Change")) {
                    String id = XmlUtil.getTextContent(elChange, "airsync:ServerId");
                    String displayName = XmlUtil.getTextContent(elChange,
                            "airsync:ApplicationData/hmfolder:DisplayName");
                    String parentID = XmlUtil.getTextContent(elChange, "airsync:ApplicationData/hmfolder:ParentId");
                    addCommand(commandMap, elChange, new FolderChangeCommand(id, displayName, parentID));
                }
                for (Element elDelete : XmlUtil.getElements(elCollection, "airsync:Commands/airsync:Delete")) {
                    String id = XmlUtil.getTextContent(elDelete, "airsync:ServerId");
                    addCommand(commandMap, elDelete, new FolderDeleteCommand(id));
                }
            }

            List<Command> commands = new ArrayList<Command>();
            for (List<Command> commandsForIndex : commandMap.values()) {
                commands.addAll(commandsForIndex);
            }

            List<SyncResponse.Collection.Response> responses = new ArrayList<SyncResponse.Collection.Response>();
            // TODO: Support for other types of responses
            for (Element elDelete : XmlUtil.getElements(elCollection, "airsync:Responses/airsync:Delete")) {
                String id = XmlUtil.getTextContent(elDelete, "airsync:ServerId");
                int deleteStatus = Integer.parseInt(XmlUtil.getTextContent(elDelete, "airsync:Status"));
                responses.add(new SyncResponse.Collection.EmailDeleteResponse(id, deleteStatus));
            }

            collections
                    .add(new SyncResponse.Collection(syncKey, clazz, status, commands, moreAvailable, responses));
        }

        SyncResponse syncResponse = new SyncResponse(collections);

        session.getLogger().debug("Got SyncResponse: {}", syncResponse);

        return syncResponse;
    }

    private Document sync(final IDeltaSyncSession session, String request) throws DeltaSyncException, IOException {
        return call("Sync", session, request, new UriCapturingResponseHandler<Document>() {

            public Document handle(URI uri, HttpResponse response) throws DeltaSyncException, IOException {

                session.setBaseUri(uri.getScheme() + "://" + uri.getHost());
                Document doc = XmlUtil.parse(response.getEntity().getContent());
                checkStatus(doc);
                if (session.getLogger().isDebugEnabled()) {
                    session.getLogger().debug("Received Sync response: {}", XmlUtil.toString(doc, false));
                }
                return doc;
            }

        });
    }

    private <T> T itemOperations(final IDeltaSyncSession session, String request,
            UriCapturingResponseHandler<T> handler) throws DeltaSyncException, IOException {

        return call("ItemOperations", session, request, handler);
    }

    private <T> T call(final String cmd, final IDeltaSyncSession session, String request,
            UriCapturingResponseHandler<T> handler) throws DeltaSyncException, IOException {

        if (session.getLogger().isDebugEnabled()) {
            try {
                Document document = XmlUtil.parse(new ByteArrayInputStream(request.getBytes()));
                session.getLogger().debug("Sending {} request: {}", cmd, XmlUtil.toString(document, false));
            } catch (XmlException e) {
                session.getLogger().debug("Sending {} request: {}", cmd, request);
            }
        }

        return post(session, session.getBaseUri() + "/DeltaSync_v2.0.0/" + cmd + ".aspx?" + session.getTicket(),
                DS_USER_AGENT, "text/xml", request, handler);
    }

    private void checkStatus(Document doc) throws DeltaSyncException {
        Element status = XmlUtil.getElement(doc.getDocumentElement(), "*:Status");
        if (status == null) {
            // All responses should have a <Status> element
            throw new DeltaSyncException("No <Status> element found in response: " + XmlUtil.toString(doc, true));
        }
        int code = Integer.parseInt(status.getTextContent().trim());
        if (code != 1) {
            String message = XmlUtil.getTextContent(doc.getDocumentElement(), "*:Fault/*:Faultstring");
            if (message == null) {
                message = "No Faultstring provided in response. Response was: " + XmlUtil.toString(doc, true);
            }
            switch (code) {
            case 3204:
                // Authentication failure. We assume this means that the session has expired.
                throw new SessionExpiredException(message);
            case 4102:
                // The server failed to understand the request due to a syntax error or an error in the parameters.
                throw new BadRequestException(message);
            case 4104:
                // Invalid sync key.
                throw new InvalidSyncKeyException(message);
            case 4402:
                throw new NoSuchFolderException(message);
            default:
                throw new UnrecognizedErrorCodeException(code, message);
            }
        }
    }

    private <T> T post(IDeltaSyncSession session, String uri, String userAgent, String contentType, Document doc,
            UriCapturingResponseHandler<T> handler) throws DeltaSyncException, IOException {

        return post(session, uri, userAgent, contentType, XmlUtil.toByteArray(doc), handler);
    }

    private <T> T post(IDeltaSyncSession session, String uri, String userAgent, String contentType, String s,
            UriCapturingResponseHandler<T> handler) throws DeltaSyncException, IOException {

        return post(session, uri, userAgent, contentType, s.getBytes("UTF-8"), handler);
    }

    private <T> T post(final IDeltaSyncSession session, String uri, final String userAgent,
            final String contentType, final byte[] data, final UriCapturingResponseHandler<T> handler)
            throws DeltaSyncException, IOException {

        final HttpPost post = createHttpPost(uri, userAgent, contentType, data);
        final HttpContext context = new BasicHttpContext();
        context.setAttribute(HttpClientContext.COOKIE_STORE, session.getCookieStore());

        try {

            return httpClient.execute(post, new ResponseHandler<T>() {
                public T handleResponse(HttpResponse response) throws ClientProtocolException, IOException {
                    try {
                        if (isRedirect(response)) {
                            URI redirectUri = getRedirectLocationURI(session, post, response, context);
                            return post(session, redirectUri.toString(), userAgent, contentType, data, handler);
                        }

                        if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
                            throw new HttpException(response.getStatusLine().getStatusCode(),
                                    response.getStatusLine().getReasonPhrase());
                        }

                        HttpRequest request = (HttpRequest) context.getAttribute(HttpCoreContext.HTTP_REQUEST);
                        HttpHost host = (HttpHost) context.getAttribute(HttpCoreContext.HTTP_TARGET_HOST);
                        URI uri;
                        try {
                            if (request instanceof HttpUriRequest) {
                                uri = ((HttpUriRequest) request).getURI();
                            } else {
                                uri = new URI(request.getRequestLine().getUri());
                            }
                            if (!uri.isAbsolute()) {
                                uri = URIUtils.rewriteURI(uri, host);
                            }
                        } catch (URISyntaxException e) {
                            throw new DeltaSyncException(e);
                        }
                        return handler.handle(uri, response);
                    } catch (DeltaSyncException e) {
                        throw new RuntimeException(e);
                    }
                }
            }, context);

        } catch (RuntimeException e) {
            Throwable t = e.getCause();
            while (t != null) {
                if (t instanceof DeltaSyncException) {
                    throw (DeltaSyncException) t;
                }
                t = t.getCause();
            }
            throw e;
        }
    }

    private HttpPost createHttpPost(String uri, String userAgent, String contentType, byte[] data) {
        ByteArrayEntity entity = new ByteArrayEntity(data);
        entity.setContentType(contentType);
        HttpPost post = new HttpPost(uri);
        post.setHeader("User-Agent", userAgent);
        post.setEntity(entity);
        post.setConfig(this.rcConfig);
        return post;
    }

    /**
     * Modified version of {@link DefaultRedirectStrategy#isRedirected(HttpRequest, HttpResponse, HttpContext)}
     * which also returns <code>true</code> for POSTs being redirected, not only for GETs and HEADs.
     */
    private boolean isRedirect(HttpResponse response) {
        int statusCode = response.getStatusLine().getStatusCode();
        Header locationHeader = response.getFirstHeader("location");
        switch (statusCode) {
        case HttpStatus.SC_MOVED_TEMPORARILY:
            return locationHeader != null;
        case HttpStatus.SC_MOVED_PERMANENTLY:
        case HttpStatus.SC_TEMPORARY_REDIRECT:
        case HttpStatus.SC_SEE_OTHER:
            return true;
        default:
            return false;
        }
    }

    /**
     * Slightly modified version of {@link DefaultRedirectStrategy#getLocationURI(HttpRequest, HttpResponse, HttpContext)}
     * which also adds the query string from the original request URI to the new URI.
     */
    private URI getRedirectLocationURI(IDeltaSyncSession session, HttpUriRequest request, HttpResponse response,
            HttpContext context) throws DeltaSyncException {

        //get the location header to find out where to redirect to
        Header locationHeader = response.getFirstHeader("location");
        if (locationHeader == null) {
            // got a redirect response, but no location header
            throw new DeltaSyncException(
                    "Received redirect response " + response.getStatusLine() + " but no location header");
        }
        String location = locationHeader.getValue();
        if (session.getLogger().isDebugEnabled()) {
            session.getLogger().debug("Redirect requested to location '" + location + "'");
        }

        URI uri = null;
        try {
            uri = new URI(location);
            if (request.getURI().getRawQuery() != null) {
                String query = request.getURI().getRawQuery();
                uri = new URI(uri.getScheme(), uri.getUserInfo(), uri.getHost(), uri.getPort(), uri.getPath(),
                        query, uri.getFragment());
            }
        } catch (URISyntaxException ex) {
            throw new DeltaSyncException("Invalid redirect URI: " + location, ex);
        }

        final HttpClientContext clientContext = HttpClientContext.adapt(context);
        final RequestConfig config = clientContext.getRequestConfig();

        // rfc2616 demands the location value be a complete URI
        // Location       = "Location" ":" absoluteURI
        try {
            if (!uri.isAbsolute()) {
                if (config.isRelativeRedirectsAllowed()) {
                    throw new DeltaSyncException("Relative redirect location '" + uri + "' not allowed");
                }
                // Adjust location URI
                HttpHost target = clientContext.getTargetHost();
                if (target == null) {
                    throw new IllegalStateException("Target host not available " + "in the HTTP context");
                }

                URI requestURI = new URI(request.getRequestLine().getUri());
                URI absoluteRequestURI = URIUtils.rewriteURI(requestURI, target, false);
                uri = URIUtils.resolve(absoluteRequestURI, uri);
            }
        } catch (URISyntaxException ex) {
            throw new DeltaSyncException(ex.getMessage(), ex);
        }

        RedirectLocations redirectLocations = (RedirectLocations) clientContext
                .getAttribute("http.protocol.redirect-locations");
        if (redirectLocations == null) {
            redirectLocations = new RedirectLocations();
            context.setAttribute("http.protocol.redirect-locations", redirectLocations);
        }

        if (config.isCircularRedirectsAllowed()) {
            URI redirectURI;
            if (uri.getFragment() != null) {
                try {
                    HttpHost target = new HttpHost(uri.getHost(), uri.getPort(), uri.getScheme());
                    redirectURI = URIUtils.rewriteURI(uri, target, true);
                } catch (URISyntaxException ex) {
                    throw new DeltaSyncException(ex.getMessage(), ex);
                }
            } else {
                redirectURI = uri;
            }

            if (redirectLocations.contains(redirectURI)) {
                throw new DeltaSyncException("Circular redirect to '" + redirectURI + "'");
            } else {
                redirectLocations.add(redirectURI);
            }
        }

        return uri;
    }

    private interface UriCapturingResponseHandler<T> {
        T handle(URI uri, HttpResponse response) throws DeltaSyncException, IOException;
    }
}