com.zimbra.client.ZMailbox.java Source code

Java tutorial

Introduction

Here is the source code for com.zimbra.client.ZMailbox.java

Source

/*
 * ***** BEGIN LICENSE BLOCK *****
 * Zimbra Collaboration Suite Server
 * Copyright (C) 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Synacor, Inc.
 *
 * This program is free software: you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software Foundation,
 * version 2 of the License.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 * You should have received a copy of the GNU General Public License along with this program.
 * If not, see <https://www.gnu.org/licenses/>.
 * ***** END LICENSE BLOCK *****
 */

package com.zimbra.client;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.http.HttpServletResponse;

import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpState;
import org.apache.commons.httpclient.cookie.CookiePolicy;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.methods.PostMethod;
import org.apache.commons.httpclient.methods.multipart.ByteArrayPartSource;
import org.apache.commons.httpclient.methods.multipart.FilePart;
import org.apache.commons.httpclient.methods.multipart.MultipartRequestEntity;
import org.apache.commons.httpclient.methods.multipart.Part;
import org.dom4j.QName;
import org.json.JSONException;

import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.zimbra.client.ZFolder.Color;
import com.zimbra.client.ZGrant.GranteeType;
import com.zimbra.client.ZInvite.ZTimeZone;
import com.zimbra.client.ZMailbox.ZOutgoingMessage.AttachedMessagePart;
import com.zimbra.client.ZSearchParams.Cursor;
import com.zimbra.client.event.ZCreateAppointmentEvent;
import com.zimbra.client.event.ZCreateContactEvent;
import com.zimbra.client.event.ZCreateConversationEvent;
import com.zimbra.client.event.ZCreateEvent;
import com.zimbra.client.event.ZCreateFolderEvent;
import com.zimbra.client.event.ZCreateMessageEvent;
import com.zimbra.client.event.ZCreateMountpointEvent;
import com.zimbra.client.event.ZCreateSearchFolderEvent;
import com.zimbra.client.event.ZCreateTagEvent;
import com.zimbra.client.event.ZCreateTaskEvent;
import com.zimbra.client.event.ZDeleteEvent;
import com.zimbra.client.event.ZEventHandler;
import com.zimbra.client.event.ZModifyAppointmentEvent;
import com.zimbra.client.event.ZModifyContactEvent;
import com.zimbra.client.event.ZModifyConversationEvent;
import com.zimbra.client.event.ZModifyEvent;
import com.zimbra.client.event.ZModifyFolderEvent;
import com.zimbra.client.event.ZModifyMailboxEvent;
import com.zimbra.client.event.ZModifyMessageEvent;
import com.zimbra.client.event.ZModifyMountpointEvent;
import com.zimbra.client.event.ZModifySearchFolderEvent;
import com.zimbra.client.event.ZModifyTagEvent;
import com.zimbra.client.event.ZModifyTaskEvent;
import com.zimbra.client.event.ZModifyVoiceMailItemEvent;
import com.zimbra.client.event.ZModifyVoiceMailItemFolderEvent;
import com.zimbra.client.event.ZRefreshEvent;
import com.zimbra.common.account.Key;
import com.zimbra.common.account.Key.AccountBy;
import com.zimbra.common.auth.ZAuthToken;
import com.zimbra.common.auth.twofactor.TOTPAuthenticator;
import com.zimbra.common.auth.twofactor.TwoFactorOptions.Encoding;
import com.zimbra.common.httpclient.HttpClientUtil;
import com.zimbra.common.localconfig.LC;
import com.zimbra.common.net.SocketFactories;
import com.zimbra.common.service.RemoteServiceException;
import com.zimbra.common.service.ServiceException;
import com.zimbra.common.soap.AccountConstants;
import com.zimbra.common.soap.AdminConstants;
import com.zimbra.common.soap.Element;
import com.zimbra.common.soap.Element.JSONElement;
import com.zimbra.common.soap.Element.XMLElement;
import com.zimbra.common.soap.HeaderConstants;
import com.zimbra.common.soap.MailConstants;
import com.zimbra.common.soap.SoapFaultException;
import com.zimbra.common.soap.SoapHttpTransport;
import com.zimbra.common.soap.SoapProtocol;
import com.zimbra.common.soap.SoapTransport;
import com.zimbra.common.soap.VoiceConstants;
import com.zimbra.common.soap.ZimbraNamespace;
import com.zimbra.common.util.ByteUtil;
import com.zimbra.common.util.Constants;
import com.zimbra.common.util.ListUtil;
import com.zimbra.common.util.MapUtil;
import com.zimbra.common.util.Pair;
import com.zimbra.common.util.StringUtil;
import com.zimbra.common.util.SystemUtil;
import com.zimbra.common.util.ZimbraHttpConnectionManager;
import com.zimbra.common.zclient.ZClientException;
import com.zimbra.soap.JaxbUtil;
import com.zimbra.soap.account.message.AuthRequest;
import com.zimbra.soap.account.message.AuthResponse;
import com.zimbra.soap.account.message.ChangePasswordRequest;
import com.zimbra.soap.account.message.ChangePasswordResponse;
import com.zimbra.soap.account.message.DisableTwoFactorAuthRequest;
import com.zimbra.soap.account.message.DisableTwoFactorAuthResponse;
import com.zimbra.soap.account.message.EnableTwoFactorAuthRequest;
import com.zimbra.soap.account.message.EnableTwoFactorAuthResponse;
import com.zimbra.soap.account.message.EndSessionRequest;
import com.zimbra.soap.account.message.GetIdentitiesRequest;
import com.zimbra.soap.account.message.GetIdentitiesResponse;
import com.zimbra.soap.account.message.GetInfoRequest;
import com.zimbra.soap.account.message.GetInfoResponse;
import com.zimbra.soap.account.message.GetSignaturesRequest;
import com.zimbra.soap.account.message.GetSignaturesResponse;
import com.zimbra.soap.account.type.AuthToken;
import com.zimbra.soap.account.type.InfoSection;
import com.zimbra.soap.mail.message.CheckSpellingRequest;
import com.zimbra.soap.mail.message.CheckSpellingResponse;
import com.zimbra.soap.mail.message.GetAppointmentRequest;
import com.zimbra.soap.mail.message.GetAppointmentResponse;
import com.zimbra.soap.mail.message.GetDataSourcesRequest;
import com.zimbra.soap.mail.message.GetDataSourcesResponse;
import com.zimbra.soap.mail.message.GetFilterRulesRequest;
import com.zimbra.soap.mail.message.GetFilterRulesResponse;
import com.zimbra.soap.mail.message.GetFolderRequest;
import com.zimbra.soap.mail.message.GetFolderResponse;
import com.zimbra.soap.mail.message.GetOutgoingFilterRulesRequest;
import com.zimbra.soap.mail.message.GetOutgoingFilterRulesResponse;
import com.zimbra.soap.mail.message.ImportContactsRequest;
import com.zimbra.soap.mail.message.ImportContactsResponse;
import com.zimbra.soap.mail.message.ModifyFilterRulesRequest;
import com.zimbra.soap.mail.message.ModifyOutgoingFilterRulesRequest;
import com.zimbra.soap.mail.type.Content;
import com.zimbra.soap.mail.type.Folder;
import com.zimbra.soap.mail.type.ImportContact;
import com.zimbra.soap.type.AccountSelector;
import com.zimbra.soap.type.CalDataSource;
import com.zimbra.soap.type.DataSource;
import com.zimbra.soap.type.ImapDataSource;
import com.zimbra.soap.type.Pop3DataSource;
import com.zimbra.soap.type.RssDataSource;
import com.zimbra.soap.type.SearchSortBy;

public class ZMailbox implements ToZJSONObject {
    public final static int MAX_NUM_CACHED_SEARCH_PAGERS = 5;
    public final static int MAX_NUM_CACHED_SEARCH_CONV_PAGERS = 5;
    public final static int MAX_NUM_CACHED_MESSAGES = LC.zmailbox_message_cachesize.intValue();
    public final static int MAX_NUM_CACHED_CONTACTS = 25;

    public final static String PATH_SEPARATOR = "/";

    public final static char PATH_SEPARATOR_CHAR = '/';

    private static final int CALENDAR_FOLDER_ALL = -1;

    static {
        SocketFactories.registerProtocols();
    }

    public static final class Fetch {
        public static final Fetch none = new Fetch("none");
        public static final Fetch first = new Fetch("first");
        public static final Fetch hits = new Fetch("hits");
        public static final Fetch all = new Fetch("all");
        public static final Fetch unread = new Fetch("unread");
        public static final Fetch u1 = new Fetch("u1");
        public static final Fetch first_msg = new Fetch("!");
        public static final Fetch hits_or_first_msg = new Fetch("hits!");
        public static final Fetch u_or_first_msg = new Fetch("u!");
        public static final Fetch u1_or_first_msg = new Fetch("u1!");

        private final String name;

        public Fetch(String name) {
            this.name = name;
        }

        private static final Map<String, Fetch> MAP = ImmutableMap.<String, Fetch>builder().put(none.name, none)
                .put(first.name, first).put(unread.name, unread).put(u1.name, u1).put(hits.name, hits)
                .put(all.name, all).put(first_msg.name, first_msg).put(hits_or_first_msg.name, hits_or_first_msg)
                .put(u_or_first_msg.name, u_or_first_msg).put(u1_or_first_msg.name, u1_or_first_msg).build();

        public static Fetch fromString(String s) throws ServiceException {
            Fetch result = MAP.get(s);
            if (result != null) {
                return result;
            } else {
                String[] ids = s.split(",");
                for (String id : ids) {
                    try {
                        Integer.parseInt(id);
                    } catch (NumberFormatException e) {
                        throw ZClientException.CLIENT_ERROR("invalid fetch: " + s, e);
                    }
                }
                return new Fetch(s);
            }
        }

        public String name() {
            return name;
        }

    }

    private enum NotifyPreference {
        nosession, full;

        static NotifyPreference fromOptions(Options options) {
            if (options == null) {
                return full;
            } else if (options.getNoSession()) {
                return nosession;
            } else {
                return full;
            }
        }
    }

    public static class Options {
        private String mAccount;
        private AccountBy mAccountBy = AccountBy.name;
        private String mPassword;
        private String mNewPassword;
        private ZAuthToken mAuthToken;
        private String mVirtualHost;
        private String mUri;
        private String mClientIp;
        private String mUserAgentName;
        private String mUserAgentVersion;
        private int mTimeout = -1;
        private int mRetryCount = -1;
        private SoapTransport.DebugListener mDebugListener;
        private SoapHttpTransport.HttpDebugListener mHttpDebugListener;
        private String mTargetAccount;
        private AccountBy mTargetAccountBy = AccountBy.name;
        private boolean mNoSession;
        private boolean mAuthAuthToken;
        private ZEventHandler mHandler;
        private List<String> mAttrs;
        private List<String> mPrefs;
        private String mRequestedSkin;
        private boolean mCsrfSupported; // Used by AuthRequest
        private Map<String, String> mCustomHeaders;
        private String mTwoFactorCode;
        private boolean mAppSpecificPasswordsSupported;
        private boolean mTrustedDevice;
        private String mTrustedDeviceToken;
        private String mDeviceId;
        private boolean mGenerateDeviceId;

        public Options() {
        }

        public Options(String account, AccountBy accountBy, String password, String uri) {
            mAccount = account;
            mAccountBy = accountBy;
            mPassword = password;
            setUri(uri);
        }

        // AP-TODO-7: retire
        public Options(String authToken, String uri) {
            mAuthToken = new ZAuthToken(null, authToken, null);
            setUri(uri);
        }

        public Options(ZAuthToken authToken, String uri) {
            mAuthToken = authToken;
            setUri(uri);
        }

        public Options(ZAuthToken authToken, String uri, boolean forceAuth, boolean csrfSupported) {
            mAuthToken = authToken;
            mAuthAuthToken = forceAuth;
            mCsrfSupported = csrfSupported;
            setUri(uri);
        }

        public String getClientIp() {
            return mClientIp;
        }

        public Options setClientIp(String clientIp) {
            mClientIp = clientIp;
            return this;
        }

        public String getAccount() {
            return mAccount;
        }

        public Options setAccount(String account) {
            mAccount = account;
            return this;
        }

        public AccountBy getAccountBy() {
            return mAccountBy;
        }

        public Options setAccountBy(AccountBy accountBy) {
            mAccountBy = accountBy;
            return this;
        }

        public String getTargetAccount() {
            return mTargetAccount;
        }

        public Options setTargetAccount(String targetAccount) {
            mTargetAccount = targetAccount;
            return this;
        }

        public AccountBy getTargetAccountBy() {
            return mTargetAccountBy;
        }

        public Options setTargetAccountBy(AccountBy targetAccountBy) {
            mTargetAccountBy = targetAccountBy;
            return this;
        }

        public String getPassword() {
            return mPassword;
        }

        public Options setPassword(String password) {
            mPassword = password;
            return this;
        }

        public String getNewPassword() {
            return mNewPassword;
        }

        public Options setNewPassword(String newPassword) {
            mNewPassword = newPassword;
            return this;
        }

        public String getVirtualHost() {
            return mVirtualHost;
        }

        public Options setVirtualHost(String virtualHost) {
            mVirtualHost = virtualHost;
            return this;
        }

        public ZAuthToken getAuthToken() {
            return mAuthToken;
        }

        public Options setAuthToken(ZAuthToken authToken) {
            mAuthToken = authToken;
            return this;
        }

        // AP-TODO-8: retire
        public Options setAuthToken(String authToken) {
            mAuthToken = new ZAuthToken(null, authToken, null);
            return this;
        }

        public String getUri() {
            return mUri;
        }

        public Options setUri(String uri) {
            setUri(uri, false);
            return this;
        }

        public Options setUri(String uri, boolean isAdmin) {
            try {
                mUri = resolveUrl(uri, isAdmin);
            } catch (ZClientException e) {
                mUri = uri;
            }
            return this;
        }

        public String getUserAgentName() {
            return mUserAgentName;
        }

        public String getUserAgentVersion() {
            return mUserAgentVersion;
        }

        public Options setUserAgent(String name, String version) {
            mUserAgentName = name;
            mUserAgentVersion = version;
            return this;
        }

        public int getTimeout() {
            return mTimeout;
        }

        public Options setTimeout(int timeout) {
            mTimeout = timeout;
            return this;
        }

        public int getRetryCount() {
            return mRetryCount;
        }

        public Options setRetryCount(int retryCount) {
            mRetryCount = retryCount;
            return this;
        }

        public SoapTransport.DebugListener getDebugListener() {
            return mDebugListener;
        }

        public Options setDebugListener(SoapTransport.DebugListener listener) {
            mDebugListener = listener;
            return this;
        }

        public SoapHttpTransport.HttpDebugListener getHttpDebugListener() {
            return mHttpDebugListener;
        }

        public Options setHttpDebugListener(SoapHttpTransport.HttpDebugListener listener) {
            mHttpDebugListener = listener;
            return this;
        }

        public boolean getNoSession() {
            return mNoSession;
        }

        public Options setNoSession(boolean noSession) {
            mNoSession = noSession;
            return this;
        }

        public boolean getAuthAuthToken() {
            return mAuthAuthToken;
        }

        /** @param authAuthToken set to true if you want to send an AuthRequest to valid the auth token */
        public Options setAuthAuthToken(boolean authAuthToken) {
            mAuthAuthToken = authAuthToken;
            return this;
        }

        public ZEventHandler getEventHandler() {
            return mHandler;
        }

        public Options setEventHandler(ZEventHandler handler) {
            mHandler = handler;
            return this;
        }

        public List<String> getPrefs() {
            return mPrefs;
        }

        public Options setPrefs(List<String> prefs) {
            mPrefs = prefs;
            return this;
        }

        public List<String> getAttrs() {
            return mAttrs;
        }

        public Options setAttrs(List<String> attrs) {
            mAttrs = attrs;
            return this;
        }

        public String getRequestedSkin() {
            return mRequestedSkin;
        }

        public Options setRequestedSkin(String skin) {
            mRequestedSkin = skin;
            return this;
        }

        public boolean getCsrfSupported() {
            return mCsrfSupported;
        }

        public Options setCsrfSupported(boolean csrfSupported) {
            mCsrfSupported = csrfSupported;
            return this;
        }

        public String getTwoFactorCode() {
            return mTwoFactorCode;
        }

        public Options setTwoFactorCode(String code) {
            mTwoFactorCode = code;
            return this;
        }

        public boolean getAppSpecificPasswordsSupported() {
            return mAppSpecificPasswordsSupported;
        }

        public Options setAppSpecificPasswordsSupported(boolean bool) {
            mAppSpecificPasswordsSupported = bool;
            return this;
        }

        public boolean getTrustedDevice() {
            return mTrustedDevice;
        }

        public Options setTrustedDevice(boolean bool) {
            mTrustedDevice = bool;
            return this;
        }

        public String getTrustedDeviceToken() {
            return mTrustedDeviceToken;
        }

        public Options setTrustedDeviceToken(String token) {
            mTrustedDeviceToken = token;
            return this;
        }

        public String getDeviceId() {
            return mDeviceId;
        }

        public Options setDeviceId(String deviceId) {
            mDeviceId = deviceId;
            return this;
        }

        public boolean getGenerateDeviceId() {
            return mGenerateDeviceId;
        }

        public Options setGenerateDeviceId(boolean bool) {
            mGenerateDeviceId = bool;
            return this;
        }

        public Map<String, String> getCustomHeaders() {
            if (mCustomHeaders == null) {
                mCustomHeaders = new HashMap<String, String>();
            }
            return mCustomHeaders;
        }
    }

    public static enum TrustedStatus {
        trusted, not_trusted;
    }

    private static class ItemCache {
        private final Map<String /* id */, ZItem> idMap;
        private final Map<String /* uuid */, ZItem> uuidMap;

        public ItemCache() {
            idMap = new HashMap<String, ZItem>();
            uuidMap = new HashMap<String, ZItem>();
        }

        public void clear() {
            idMap.clear();
            uuidMap.clear();
        }

        public void put(ZItem item) {
            putWithId(item.getId(), item);
        }

        public void putWithId(String id, ZItem item) {
            idMap.put(id, item);
            if (item.getUuid() != null) {
                uuidMap.put(item.getUuid(), item);
            }
        }

        public ZItem getById(String id) {
            return idMap.get(id);
        }

        public ZItem getByUuid(String uuid) {
            return uuidMap.get(uuid);
        }

        public ZItem removeById(String id) {
            ZItem removed = idMap.remove(id);
            if (removed != null && removed.getUuid() != null) {
                uuidMap.remove(removed.getUuid());
            }
            return removed;
        }
    }

    private ZAuthToken mAuthToken;
    private String mCsrfToken;
    private String mTrustedToken;
    private SoapHttpTransport mTransport;
    private NotifyPreference mNotifyPreference;
    private Map<String, ZTag> mNameToTag;
    private ItemCache mItemCache;
    private ZGetInfoResult mGetInfoResult;
    private ZFolder mUserRoot;
    private ZSearchPagerCache mSearchPagerCache;
    private ZSearchPagerCache mSearchConvPagerCache;
    private ZApptSummaryCache mApptSummaryCache;
    private Map<String, CachedMessage> mMessageCache;
    private Map<String, ZContact> mContactCache;
    private ZFilterRules incomingRules;
    private ZFilterRules outgoingRules;
    private ZAuthResult mAuthResult;
    private String mClientIp;
    private List<ZPhoneAccount> mPhoneAccounts;
    private Map<String, ZPhoneAccount> mPhoneAccountMap;
    private Element mVoiceStorePrincipal;
    private long mSize;
    private boolean mNoTagCache;
    private ZContactByPhoneCache mContactByPhoneCache;

    private final List<ZEventHandler> mHandlers = new ArrayList<ZEventHandler>();

    public static ZMailbox getMailbox(Options options) throws ServiceException {
        return new ZMailbox(options);
    }

    /**
     * for use with changePassword
     */
    private ZMailbox() {
    }

    /**
     * change password. You must pass in an options with an account, password, newPassword, and Uri.
     * @param options uri/name/pass/newPass
     * @throws ServiceException on error
     */
    public static ZChangePasswordResult changePassword(Options options) throws ServiceException {
        ZMailbox mailbox = new ZMailbox();
        mailbox.mClientIp = options.getClientIp();
        mailbox.mNotifyPreference = NotifyPreference.fromOptions(options);
        mailbox.initPreAuth(options);
        return mailbox.changePassword(options.getAccount(), options.getAccountBy(), options.getPassword(),
                options.getNewPassword(), options.getVirtualHost());
    }

    public static ZMailbox getByName(String name, String password, String uri) throws ServiceException {
        return new ZMailbox(new Options(name, AccountBy.name, password, uri));
    }

    public static ZMailbox getByAuthToken(String authToken, String uri) throws ServiceException {
        return new ZMailbox(new Options(authToken, uri));
    }

    public static ZMailbox getByAuthToken(ZAuthToken authToken, String uri, boolean forceAuth,
            boolean csrfSupported) throws ServiceException {
        return new ZMailbox(new Options(authToken, uri, forceAuth, csrfSupported));
    }

    public ZMailbox(Options options) throws ServiceException {
        mHandlers.add(new InternalEventHandler());
        mSearchPagerCache = new ZSearchPagerCache(MAX_NUM_CACHED_SEARCH_PAGERS, true);
        mHandlers.add(mSearchPagerCache);
        mSearchConvPagerCache = new ZSearchPagerCache(MAX_NUM_CACHED_SEARCH_CONV_PAGERS, false);
        mHandlers.add(mSearchConvPagerCache);
        mMessageCache = MapUtil.newLruMap(MAX_NUM_CACHED_MESSAGES);
        mContactCache = MapUtil.newLruMap(MAX_NUM_CACHED_CONTACTS);
        mApptSummaryCache = new ZApptSummaryCache();
        mHandlers.add(mApptSummaryCache);
        if (options.getEventHandler() != null) {
            mHandlers.add(options.getEventHandler());
        }

        mNotifyPreference = NotifyPreference.fromOptions(options);

        mClientIp = options.getClientIp();

        initPreAuth(options);
        if (options.getAuthToken() != null) {
            if (options.getAuthAuthToken()) {
                mAuthResult = authByAuthToken(options);
                initCsrfToken(mAuthResult.getCsrfToken());
                initAuthToken(mAuthResult.getAuthToken());
                initTrustedToken(mAuthResult.getTrustedToken());
            } else {
                initAuthToken(options.getAuthToken());
            }
        } else if (options.getAccount() != null) {
            String password;
            if (options.getNewPassword() != null) {
                changePassword(options.getAccount(), options.getAccountBy(), options.getPassword(),
                        options.getNewPassword(), options.getVirtualHost());
                password = options.getNewPassword();
            } else {
                password = options.getPassword();
            }
            mAuthResult = authByPassword(options, password);
            initAuthToken(mAuthResult.getAuthToken());
            initCsrfToken(mAuthResult.getCsrfToken());
            initTrustedToken(mAuthResult.getTrustedToken());
        }
        if (options.getTargetAccount() != null) {
            initTargetAccount(options.getTargetAccount(), options.getTargetAccountBy());
        }
    }

    public boolean addEventHandler(ZEventHandler handler) {
        if (!mHandlers.contains(handler)) {
            mHandlers.add(handler);
            return true;
        } else {
            return false;
        }
    }

    public boolean removeEventHandler(ZEventHandler handler) {
        return mHandlers.remove(handler);
    }

    public void initAuthToken(ZAuthToken authToken) {
        mAuthToken = authToken;
        mTransport.setAuthToken(mAuthToken);
    }

    public void initCsrfToken(String csrfToken) {
        mCsrfToken = csrfToken;
        mTransport.setCsrfToken(mCsrfToken);
    }

    public void initTrustedToken(String trustedToken) {
        mTrustedToken = trustedToken;
        mTransport.setTrustedToken(mTrustedToken);
    }

    private void initPreAuth(Options options) {
        mItemCache = new ItemCache();
        setSoapURI(options);
        if (options.getDebugListener() != null) {
            mTransport.setDebugListener(options.getDebugListener());
        } else if (options.getHttpDebugListener() != null) {
            mTransport.setHttpDebugListener(options.getHttpDebugListener());
        }
    }

    private void initTargetAccount(String key, AccountBy by) {
        if (AccountBy.id.equals(by)) {
            mTransport.setTargetAcctId(key);
        } else if (AccountBy.name.equals(by)) {
            mTransport.setTargetAcctName(key);
        }
    }

    public Element newRequestElement(QName name) {
        if (mTransport.getRequestProtocol() == SoapProtocol.SoapJS) {
            return new JSONElement(name);
        } else {
            return new XMLElement(name);
        }
    }

    private ZChangePasswordResult changePassword(String key, AccountBy by, String oldPassword, String newPassword,
            String virtualHost) throws ServiceException {
        if (mTransport == null) {
            throw ZClientException.CLIENT_ERROR("must call setURI before calling changePassword", null);
        }

        AccountSelector account = new AccountSelector(SoapConverter.TO_SOAP_ACCOUNT_BY.apply(by), key);
        ChangePasswordRequest req = new ChangePasswordRequest(account, oldPassword, newPassword);
        req.setVirtualHost(virtualHost);

        ChangePasswordResponse res = invokeJaxb(req);
        return new ZChangePasswordResult(res);
    }

    private void addAttrsAndPrefs(AuthRequest req, Options options) {
        List<String> prefs = options.getPrefs();
        if (!ListUtil.isEmpty(prefs)) {
            for (String p : prefs) {
                req.addPref(p);
            }
        }
        List<String> attrs = options.getAttrs();
        if (!ListUtil.isEmpty(attrs)) {
            for (String a : attrs) {
                req.addAttr(a);
            }
        }
    }

    public ZAuthResult authByPassword(Options options, String password) throws ServiceException {
        if (mTransport == null) {
            throw ZClientException.CLIENT_ERROR("must call setURI before calling authenticate", null);
        }

        AccountSelector account = new AccountSelector(com.zimbra.soap.type.AccountBy.name, options.getAccount());
        AuthRequest auth = new AuthRequest(account, password);
        auth.setPassword(password);
        auth.setTwoFactorCode(options.getTwoFactorCode());
        auth.setVirtualHost(options.getVirtualHost());
        auth.setRequestedSkin(options.getRequestedSkin());
        auth.setCsrfSupported(options.getCsrfSupported());
        auth.setDeviceTrusted(options.getTrustedDevice());
        if (options.getTrustedDevice()) {
            auth.setDeviceTrusted(true);
        }
        if (options.getAuthToken() != null) {
            auth.setAuthToken(new AuthToken(options.getAuthToken().getValue(), false));
        }
        if (options.getDeviceId() != null) {
            auth.setDeviceId(options.getDeviceId());
        }
        if (options.getTrustedDeviceToken() != null) {
            auth.setTrustedDeviceToken(options.getTrustedDeviceToken());
        }
        if (options.getGenerateDeviceId()) {
            auth.setGenerateDeviceId(true);
        }
        addAttrsAndPrefs(auth, options);

        AuthResponse authRes = invokeJaxb(auth);
        ZAuthResult r = new ZAuthResult(authRes);
        r.setSessionId(mTransport.getSessionId());
        return r;
    }

    public ZAuthResult authByAuthToken(Options options) throws ServiceException {
        if (mTransport == null) {
            throw ZClientException.CLIENT_ERROR("must call setURI before calling authenticate", null);
        }

        AuthRequest req = new AuthRequest();
        ZAuthToken zat = options.getAuthToken(); // cannot be null here
        req.setAuthToken(new AuthToken(zat.getValue(), false));
        req.setTwoFactorCode(options.getTwoFactorCode());
        req.setRequestedSkin(options.getRequestedSkin());
        req.setCsrfSupported(options.getCsrfSupported());
        req.setDeviceTrusted(options.getTrustedDevice());
        addAttrsAndPrefs(req, options);

        AuthResponse res = invokeJaxb(req);
        ZAuthResult r = new ZAuthResult(res);
        r.setSessionId(mTransport.getSessionId());
        return r;
    }

    public ZAuthResult getAuthResult() {
        return mAuthResult;
    }

    public ZAuthToken getAuthToken() {
        return mAuthToken;
    }

    public String getCsrfToken() {
        return mCsrfToken;
    }

    public String getTrustedToken() {
        return mTrustedToken;
    }

    /**
     * @param uri URI of server we want to talk to
     * @param timeout timeout for HTTP connection or 0 for no timeout
     * @param retryCount max number of times to retry the call on connection failure
     */
    private void setSoapURI(Options options) {
        if (mTransport != null) {
            mTransport.shutdown();
        }
        mTransport = new SoapHttpTransport(options.getUri());
        if (options.getUserAgentName() == null) {
            mTransport.setUserAgent("zclient", SystemUtil.getProductVersion());
        } else {
            mTransport.setUserAgent(options.getUserAgentName(), options.getUserAgentVersion());
        }
        mTransport.setMaxNotifySeq(0);
        mTransport.setClientIp(mClientIp);
        if (options.getTimeout() > -1) {
            mTransport.setTimeout(options.getTimeout());
        }
        if (options.getRetryCount() != -1) {
            mTransport.setRetryCount(options.getRetryCount());
        }
        if (mAuthToken != null) {
            mTransport.setAuthToken(mAuthToken);
        }
        if (mCsrfToken != null) {
            mTransport.setCsrfToken(mCsrfToken);
        }
        for (Map.Entry<String, String> entry : options.getCustomHeaders().entrySet()) {
            mTransport.getCustomHeaders().put(entry.getKey(), entry.getValue());
        }
    }

    @SuppressWarnings("unchecked")
    public <T> T invokeJaxb(Object jaxbObject) throws ServiceException {
        Element req = JaxbUtil.jaxbToElement(jaxbObject);
        Element res = invoke(req);
        return (T) JaxbUtil.elementToJaxb(res);
    }

    @SuppressWarnings("unchecked")
    public <T> T invokeJaxbOnTargetAccount(Object jaxbObject, String requestedAccountId) throws ServiceException {
        Element req = JaxbUtil.jaxbToElement(jaxbObject);
        Element res = invoke(req, requestedAccountId);
        return (T) JaxbUtil.elementToJaxb(res);
    }

    public Element invoke(Element request) throws ServiceException {
        return invoke(request, null);
    }

    public synchronized Element invoke(Element request, String requestedAccountId) throws ServiceException {
        try {
            boolean nosession = mNotifyPreference == NotifyPreference.nosession;
            Element response = mTransport.invoke(request, false, nosession, requestedAccountId);
            return response;
        } catch (SoapFaultException e) {
            throw e; // for now, later, try to map to more specific exception
        } catch (Exception e) {
            Throwable t = SystemUtil.getInnermostException(e);
            RemoteServiceException.doConnectionFailures(mTransport.getURI(), t);
            RemoteServiceException.doSSLFailures(t.getMessage(), t);
            if (e instanceof IOException) {
                throw ZClientException.IO_ERROR(e.getMessage(), e);
            }
            throw ServiceException.FAILURE(e.getMessage(), e);
        } finally {
            Element context = mTransport.getZimbraContext();
            mTransport.clearZimbraContext();
            handleResponseContext(context);
        }
    }

    private void handleResponseContext(Element context) throws ServiceException {
        if (context == null) {
            return;
        }
        // handle refresh blocks
        Element refresh = context.getOptionalElement(ZimbraNamespace.E_REFRESH);
        if (refresh != null) {
            handleRefresh(refresh);
        }

        for (Element notify : context.listElements(ZimbraNamespace.E_NOTIFY)) {
            mTransport.setMaxNotifySeq(
                    Math.max(mTransport.getMaxNotifySeq(), notify.getAttributeLong(HeaderConstants.A_SEQNO, 0)));
            // MUST DO IN THIS ORDER!
            handleDeleted(notify.getOptionalElement(ZimbraNamespace.E_DELETED));
            handleCreated(notify.getOptionalElement(ZimbraNamespace.E_CREATED));
            handleModified(notify.getOptionalElement(ZimbraNamespace.E_MODIFIED));
        }
    }

    private void handleRefresh(Element refresh) throws ServiceException {
        for (Element mbx : refresh.listElements(MailConstants.E_MAILBOX)) {
            // FIXME: logic should be different if ZMailbox points at another user's mailbox
            if (mbx.getAttribute(HeaderConstants.A_ACCOUNT_ID, null) == null) {
                mSize = mbx.getAttributeLong(MailConstants.A_SIZE);
            }
        }

        Element tags = refresh.getOptionalElement(ZimbraNamespace.E_TAGS);
        List<ZTag> tagList = new ArrayList<ZTag>();
        if (tags != null) {
            for (Element t : tags.listElements(MailConstants.E_TAG)) {
                ZTag tag = new ZTag(t, this);
                tagList.add(tag);
            }
        }
        Element folderEl = refresh.getOptionalElement(MailConstants.E_FOLDER);
        ZFolder userRoot = new ZFolder(folderEl, null, this);
        ZRefreshEvent event = new ZRefreshEvent(mSize, userRoot, tagList);
        for (ZEventHandler handler : mHandlers) {
            handler.handleRefresh(event, this);
        }
        incomingRules = null;
        outgoingRules = null;
    }

    private void handleModified(Element modified) throws ServiceException {
        if (modified == null) {
            return;
        }
        for (Element e : modified.listElements()) {
            ZModifyEvent event = null;
            if (e.getName().equals(MailConstants.E_CONV)) {
                event = new ZModifyConversationEvent(e);
            } else if (e.getName().equals(MailConstants.E_MSG)) {
                event = new ZModifyMessageEvent(e);
            } else if (e.getName().equals(MailConstants.E_TAG)) {
                event = new ZModifyTagEvent(e);
            } else if (e.getName().equals(MailConstants.E_CONTACT)) {
                event = new ZModifyContactEvent(e);
            } else if (e.getName().equals(MailConstants.E_SEARCH)) {
                event = new ZModifySearchFolderEvent(e);
            } else if (e.getName().equals(MailConstants.E_FOLDER)) {
                event = new ZModifyFolderEvent(e);
            } else if (e.getName().equals(MailConstants.E_MOUNT)) {
                event = new ZModifyMountpointEvent(e);
            } else if (e.getName().equals(MailConstants.E_MAILBOX)) {
                event = new ZModifyMailboxEvent(e);
            } else if (e.getName().equals(MailConstants.E_APPOINTMENT)) {
                event = new ZModifyAppointmentEvent(e);
            } else if (e.getName().equals(MailConstants.E_TASK)) {
                event = new ZModifyTaskEvent(e);
            }
            if (event != null) {
                handleEvent(event);
            }
        }
    }

    private void handleEvent(ZModifyEvent event) throws ServiceException {
        for (ZEventHandler handler : mHandlers) {
            handler.handleModify(event, this);
        }
    }

    private List<ZFolder> parentCheck(List<ZFolder> list, ZFolder f, ZFolder parent) {
        if (parent != null) {
            parent.addChild(f);
        } else {
            if (list == null) {
                list = new ArrayList<ZFolder>();
            }
            list.add(f);
        }
        return list;
    }

    private void handleCreated(Element created) throws ServiceException {
        if (created == null) {
            return;
        }
        List<ZCreateEvent> events = null;
        List<ZFolder> parentFixup = null;
        for (Element e : created.listElements()) {
            ZCreateEvent event = null;
            if (e.getName().equals(MailConstants.E_CONV)) {
                event = new ZCreateConversationEvent(e);
            } else if (e.getName().equals(MailConstants.E_MSG)) {
                event = new ZCreateMessageEvent(e);
            } else if (e.getName().equals(MailConstants.E_CONTACT)) {
                event = new ZCreateContactEvent(e);
            } else if (e.getName().equals(MailConstants.E_APPOINTMENT)) {
                event = new ZCreateAppointmentEvent(e);
            } else if (e.getName().equals(MailConstants.E_TASK)) {
                event = new ZCreateTaskEvent(e);
            } else if (e.getName().equals(MailConstants.E_FOLDER)) {
                String parentId = e.getAttribute(MailConstants.A_FOLDER);
                ZFolder parent = getFolderById(parentId);
                ZFolder child = new ZFolder(e, parent, this);
                addItemIdMapping(child);
                event = new ZCreateFolderEvent(child);
                parentFixup = parentCheck(parentFixup, child, parent);
            } else if (e.getName().equals(MailConstants.E_MOUNT)) {
                String parentId = e.getAttribute(MailConstants.A_FOLDER);
                ZFolder parent = getFolderById(parentId);
                ZMountpoint child = new ZMountpoint(e, parent, this);
                addItemIdMapping(child);
                addRemoteItemIdMapping(child.getCanonicalRemoteId(), child);
                parentFixup = parentCheck(parentFixup, child, parent);
                event = new ZCreateMountpointEvent(child);
            } else if (e.getName().equals(MailConstants.E_SEARCH)) {
                String parentId = e.getAttribute(MailConstants.A_FOLDER);
                ZFolder parent = getFolderById(parentId);
                ZSearchFolder child = new ZSearchFolder(e, parent, this);
                addItemIdMapping(child);
                event = new ZCreateSearchFolderEvent(child);
                parentFixup = parentCheck(parentFixup, child, parent);
            } else if (e.getName().equals(MailConstants.E_TAG)) {
                event = new ZCreateTagEvent(new ZTag(e, this));
                addTag(((ZCreateTagEvent) event).getTag());
            }
            if (event != null) {
                if (events == null) {
                    events = new ArrayList<ZCreateEvent>();
                }
                events.add(event);
            }
        }

        if (parentFixup != null) {
            for (ZFolder f : parentFixup) {
                ZFolder parent = getFolderById(f.getParentId());
                if (parent != null) {
                    parent.addChild(f);
                    f.setParent(parent);
                }
            }
        }

        if (events != null) {
            for (ZCreateEvent event : events) {
                for (ZEventHandler handler : mHandlers) {
                    handler.handleCreate(event, this);
                }
            }
        }
    }

    private void handleDeleted(Element deleted) throws ServiceException {
        if (deleted == null) {
            return;
        }
        String ids = deleted.getAttribute(MailConstants.A_ID, null);
        if (ids == null) {
            return;
        }
        ZDeleteEvent de = new ZDeleteEvent(ids);
        for (ZEventHandler handler : mHandlers) {
            handler.handleDelete(de, this);
        }
    }

    private void addIdMappings(ZFolder folder) {
        if (folder == null) {
            return;
        }
        addItemIdMapping(folder);
        if (folder instanceof ZMountpoint) {
            ZMountpoint mp = (ZMountpoint) folder;
            addRemoteItemIdMapping(mp.getCanonicalRemoteId(), mp);
        }
        for (ZFolder child : folder.getSubFolders()) {
            addIdMappings(child);
        }
    }

    class InternalEventHandler extends ZEventHandler {
        @Override
        public synchronized void handleRefresh(ZRefreshEvent event, ZMailbox mailbox) {
            ZFolder root = event.getUserRoot();
            List<ZTag> tags = event.getTags();

            mItemCache.clear();
            mMessageCache.clear();
            mContactCache.clear();
            mTransport.setMaxNotifySeq(0);
            mSize = event.getSize();
            if (root != null) {
                mUserRoot = root;
                addIdMappings(mUserRoot);
            }
            if (tags != null) {
                if (mNameToTag == null) {
                    mNameToTag = new HashMap<String, ZTag>();
                } else {
                    mNameToTag.clear();
                }
                for (ZTag tag : tags) {
                    addTag(tag);
                }
            }
        }

        @Override
        public synchronized void handleCreate(ZCreateEvent event, ZMailbox mailbox) {
            // do nothing
        }

        @Override
        public synchronized void handleModify(ZModifyEvent event, ZMailbox mailbox) throws ServiceException {
            if (event instanceof ZModifyTagEvent) {
                ZModifyTagEvent tagEvent = (ZModifyTagEvent) event;
                ZTag tag = getTagById(tagEvent.getId());
                if (tag != null) {
                    String oldName = tag.getName();
                    tag.modifyNotification(tagEvent);
                    if (mNameToTag != null && !tag.getName().equalsIgnoreCase(oldName)) {
                        mNameToTag.remove(oldName);
                        mNameToTag.put(tag.getName(), tag);
                    }
                }
            } else if (event instanceof ZModifyFolderEvent) {
                ZModifyFolderEvent mfe = (ZModifyFolderEvent) event;
                ZFolder f = getFolderById(mfe.getId());
                if (f != null) {
                    String newParentId = mfe.getParentId(null);
                    if (newParentId != null && !newParentId.equals(f.getParentId())) {
                        reparent(f, newParentId);
                    }
                    f.modifyNotification(event);
                }
            } else if (event instanceof ZModifyMailboxEvent) {
                ZModifyMailboxEvent mme = (ZModifyMailboxEvent) event;
                // FIXME: logic should be different if ZMailbox points at another user's mailbox
                if (mme.getOwner(null) == null) {
                    mSize = mme.getSize(mSize);
                }
            } else if (event instanceof ZModifyMessageEvent) {
                ZModifyMessageEvent mme = (ZModifyMessageEvent) event;
                CachedMessage cm = mMessageCache.get(mme.getId());
                if (cm != null) {
                    cm.zm.modifyNotification(event);
                }
            } else if (event instanceof ZModifyContactEvent) {
                ZModifyContactEvent mce = (ZModifyContactEvent) event;
                ZContact contact = mContactCache.get(mce.getId());
                if (contact != null) {
                    contact.modifyNotification(mce);
                }
            }
        }

        @Override
        public synchronized void handleDelete(ZDeleteEvent event, ZMailbox mailbox) {
            for (String id : event.toList()) {
                mMessageCache.remove(id);
                mContactCache.remove(id);
                ZItem item = mItemCache.getById(id);
                if (item instanceof ZMountpoint) {
                    ZMountpoint sl = (ZMountpoint) item;
                    if (sl.getParent() != null) {
                        sl.getParent().removeChild(sl);
                    }
                    mItemCache.removeById(sl.getCanonicalRemoteId());
                } else if (item instanceof ZFolder) {
                    ZFolder sf = (ZFolder) item;
                    if (sf.getParent() != null) {
                        sf.getParent().removeChild(sf);
                    }

                } else if (item instanceof ZTag) {
                    if (mNameToTag != null) {
                        mNameToTag.remove(((ZTag) item).getName());
                    }
                }
                if (item != null) {
                    mItemCache.removeById(item.getId());
                }
            }
        }
    }

    private void addTag(ZTag tag) {
        if (mNameToTag != null) {
            mNameToTag.put(tag.getName(), tag);
        }
        addItemIdMapping(tag);
    }

    void addItemIdMapping(ZItem item) {
        mItemCache.put(item);
    }

    void addRemoteItemIdMapping(String remoteId, ZItem item) {
        mItemCache.putWithId(remoteId, item);
    }

    private void reparent(ZFolder f, String newParentId) throws ServiceException {
        ZFolder parent = f.getParent();
        if (parent != null) {
            parent.removeChild(f);
        }
        ZFolder newParent = getFolderById(newParentId);
        if (newParent != null) {
            newParent.addChild(f);
            f.setParent(newParent);
        }
    }

    /**
     * returns the parent folder path. First removes a trailing {@link #PATH_SEPARATOR} if one is present, then
     * returns the value of the path preceeding the last {@link #PATH_SEPARATOR} in the path.
     * @param path path must be absolute
     * @throws ServiceException if an error occurs
     * @return the parent folder path
     */
    public static String getParentPath(String path) throws ServiceException {
        if (path.equals(PATH_SEPARATOR)) {
            return PATH_SEPARATOR;
        }
        if (path.charAt(0) != PATH_SEPARATOR_CHAR) {
            throw ServiceException.INVALID_REQUEST("path must be absolute: " + path, null);
        }
        if (path.charAt(path.length() - 1) == PATH_SEPARATOR_CHAR) {
            path = path.substring(0, path.length() - 1);
        }
        int index = path.lastIndexOf(PATH_SEPARATOR_CHAR);
        path = path.substring(0, index);
        if (path.length() == 0) {
            return PATH_SEPARATOR;
        } else {
            return path;
        }
    }

    /**
     * returns the base folder path. First removes a trailing {@link #PATH_SEPARATOR} if one is present, then
     * returns the value of the path trailing the last {@link #PATH_SEPARATOR} in the path.
     * @throws ServiceException if an error occurs
     * @return base path
     * @param path the path we are getting the base from
     */
    public static String getBasePath(String path) throws ServiceException {
        if (path.equals(PATH_SEPARATOR)) {
            return PATH_SEPARATOR;
        }
        if (path.charAt(0) != PATH_SEPARATOR_CHAR) {
            throw ServiceException.INVALID_REQUEST("path must be absolute: " + path, null);
        }
        if (path.charAt(path.length() - 1) == PATH_SEPARATOR_CHAR) {
            path = path.substring(0, path.length() - 1);
        }
        int index = path.lastIndexOf(PATH_SEPARATOR_CHAR);
        return path.substring(index + 1);
    }

    /**
     * @return current size of mailbox in bytes
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public long getSize() throws ServiceException {
        populateFolderCache();
        return mSize;
    }

    /**
     * @return account name of mailbox
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public String getName() throws ServiceException {
        return getAccountInfo(false).getName();
    }

    public ZPrefs getPrefs() throws ServiceException {
        return getPrefs(false);
    }

    public ZPrefs getPrefs(boolean refresh) throws ServiceException {
        return getAccountInfo(refresh).getPrefs();
    }

    public ZFeatures getFeatures() throws ServiceException {
        return getFeatures(false);
    }

    public ZFeatures getFeatures(boolean refresh) throws ServiceException {
        return getAccountInfo(refresh).getFeatures();
    }

    public ZLicenses getLicenses() throws ServiceException {
        return getLicenses(false);
    }

    public ZLicenses getLicenses(boolean refresh) throws ServiceException {
        return getAccountInfo(refresh).getLicenses();
    }

    private static Set<InfoSection> NOT_ZIMLETS = Collections
            .unmodifiableSet(EnumSet.complementOf(EnumSet.of(InfoSection.zimlets)));

    public ZGetInfoResult getAccountInfo(boolean refresh) throws ServiceException {
        if (mGetInfoResult == null || refresh) {
            GetInfoRequest req = new GetInfoRequest(NOT_ZIMLETS);
            GetInfoResponse res = invokeJaxb(req);
            mGetInfoResult = new ZGetInfoResult(res);
        }
        return mGetInfoResult;
    }

    public int getTimeout() {
        return mTransport.getTimeout();
    }

    public String maskRemoteItemId(String folderId, String id) throws ServiceException {
        int folderIndex = folderId.indexOf(':');
        int idIndex = id.indexOf(':');
        if (folderIndex != -1 && idIndex != -1) {
            ZFolder f = getFolderById(folderId);
            if (f != null) {
                String folderPrefix = folderId.substring(0, folderIndex);
                String idPrefix = id.substring(0, idIndex);
                if (folderPrefix.equalsIgnoreCase(idPrefix)) {
                    return f.getId() + ":" + id.substring(idIndex + 1);
                }
            }
        }
        return id;
    }

    public String unmaskRemoteItemId(String id) throws ServiceException {
        int idIndex = id.indexOf(':');
        if (idIndex != -1) {
            String idPrefix = id.substring(0, idIndex);
            ZMountpoint mp = getMountpointById(idPrefix);
            if (mp != null) {
                return mp.getOwnerId() + ":" + id.substring(idIndex + 1);
            }
        }
        return id;
    }

    //  ------------------------

    /**
     * @return current List of all tags in the mailbox
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public List<ZTag> getAllTags() throws ServiceException {
        populateTagCache();
        List<ZTag> result = new ArrayList<ZTag>(mNameToTag.values());
        Collections.sort(result);
        return result;
    }

    /**
     *
     * @return true if mailbox has any tags
     * @throws ServiceException
     */
    public boolean hasTags() throws ServiceException {
        populateTagCache();
        return !mNameToTag.isEmpty();
    }

    /**
     * @return current list of all tags names in the mailbox, sorted
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public List<String> getAllTagNames() throws ServiceException {
        populateTagCache();
        ArrayList<String> names = new ArrayList<String>(mNameToTag.keySet());
        Collections.sort(names);
        return names;
    }

    /**
     * returns the tag the specified name/id, or null if no such tag exists.
     * checks for tag by name first, then by id.
     *
     * @param name tag name
     * @return the tag, or null if tag not found
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZTag getTag(String nameOrId) throws ServiceException {
        ZTag result = getTagByName(nameOrId);
        return result != null ? result : getTagById(nameOrId);
    }

    /**
     * returns the tag the specified name, or null if no such tag exists.
     *
     * @param name tag name
     * @return the tag, or null if tag not found
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZTag getTagByName(String name) throws ServiceException {
        populateTagCache();
        return mNameToTag.get(name);
    }

    /**
     * returns the tag with the specified id, or null if no such tag exists.
     *
     * @param id the tag id
     * @return tag with given id, or null
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZTag getTagById(String id) throws ServiceException {
        populateTagCache();
        ZItem item = mItemCache.getById(id);
        if (item instanceof ZTag) {
            return (ZTag) item;
        } else {
            return null;
        }
    }

    private static final Pattern sCOMMA = Pattern.compile(",");

    /**
     * returns the tags for the specified ids.  Ignores id's that don't
     * reference existing tags.
     *
     * @param ids the tag ids
     * @return the tag list, or an empty list if no ids match
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public List<ZTag> getTags(String ids) throws ServiceException {
        List<ZTag> tags = new ArrayList<ZTag>();
        if (!StringUtil.isNullOrEmpty(ids)) {
            for (String id : sCOMMA.split(ids)) {
                ZTag tag = getTagById(id);
                if (tag != null) {
                    tags.add(tag);
                }
            }
        }
        return tags;
    }

    /**
     * create a new tag with the specified color.
     *
     * @return newly created tag
     * @param name name of the tag
     * @param color optional color of the tag
     * @throws com.zimbra.common.service.ServiceException if an error occurs
     *
     */
    public ZTag createTag(String name, ZTag.Color color) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_TAG_REQUEST);
        Element tagEl = req.addUniqueElement(MailConstants.E_TAG);
        tagEl.addAttribute(MailConstants.A_NAME, name);
        if (color != null) {
            if (color == ZTag.Color.rgbColor) {
                tagEl.addAttribute(MailConstants.A_RGB, color.getRgbColor());
            } else {
                tagEl.addAttribute(MailConstants.A_COLOR, color.getValue());
            }
        }
        Element createdTagEl = invoke(req).getElement(MailConstants.E_TAG);
        ZTag tag = getTagById(createdTagEl.getAttribute(MailConstants.A_ID));
        return tag != null ? tag : new ZTag(createdTagEl, this);
    }

    /**
     * update a tag
     * @return action result
     * @param id id of tag to update
     * @param name new name of tag
     * @param color color of tag to modify
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZActionResult updateTag(String id, String name, ZTag.Color color) throws ServiceException {
        Element action = tagAction("update", id);
        if (color != null) {
            if (color == ZTag.Color.rgbColor) {
                action.addAttribute(MailConstants.A_RGB, color.getRgbColor());
            } else {
                action.addAttribute(MailConstants.A_COLOR, color.getValue());
            }
        }
        if (name != null && name.length() > 0) {
            action.addAttribute(MailConstants.A_NAME, name);
        }
        return doAction(action);
    }

    /**
     * modifies the tag's color
     * @return action result
     * @param id id of tag to modify
     * @param color color of tag to modify
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZActionResult modifyTagColor(String id, ZTag.Color color) throws ServiceException {
        if (color == ZTag.Color.rgbColor) {
            return doAction(tagAction("color", id).addAttribute(MailConstants.A_RGB, color.getRgbColor()));
        } else {
            return doAction(tagAction("color", id).addAttribute(MailConstants.A_COLOR, color.getValue()));
        }
    }

    /** mark all items with tag as read
     * @param id id of tag to mark read
     * @return action reslult
     * @throws ServiceException on error
     */
    public ZActionResult markTagRead(String id) throws ServiceException {
        return doAction(tagAction("read", id));
    }

    /**
     * delete tag
     * @param id id of tag to delete
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult deleteTag(String id) throws ServiceException {
        return doAction(tagAction("delete", id));
    }

    /**
     * rename tag
     * @param id id of tag
     * @param name new name of tag
     * @throws ServiceException on error
     * @return action result
     */
    public ZActionResult renameTag(String id, String name) throws ServiceException {
        return doAction(tagAction("rename", id).addAttribute(MailConstants.A_NAME, name));
    }

    private Element tagAction(String op, String id) {
        Element req = newRequestElement(MailConstants.TAG_ACTION_REQUEST);
        Element actionEl = req.addUniqueElement(MailConstants.E_ACTION);
        actionEl.addAttribute(MailConstants.A_ID, id);
        actionEl.addAttribute(MailConstants.A_OPERATION, op);
        return actionEl;
    }

    private ZActionResult doAction(Element actionEl) throws ServiceException {
        Element response = invoke(actionEl.getParent());
        return new ZActionResult(response);
    }

    // ------------------------

    public enum ContactSortBy {

        nameDesc, nameAsc;

        public static ContactSortBy fromString(String s) throws ServiceException {
            try {
                return ContactSortBy.valueOf(s);
            } catch (IllegalArgumentException e) {
                throw ZClientException.CLIENT_ERROR(
                        "invalid sortBy: " + s + ", valid values: " + Arrays.asList(ContactSortBy.values()), e);
            }
        }
    }

    /**
     *
     * @param optFolderId return contacts only in specified folder (null for all folders)
     * @param sortBy sort results (null for no sorting)
     * @param sync if true, return modified date on contacts
     * @return list of contacts
     * @throws ServiceException on error
     * @param attrs specified attrs to return, or null for all.
     */
    public List<ZContact> getAllContacts(String optFolderId, ContactSortBy sortBy, boolean sync, List<String> attrs)
            throws ServiceException {
        Element req = newRequestElement(MailConstants.GET_CONTACTS_REQUEST);
        if (optFolderId != null) {
            req.addAttribute(MailConstants.A_FOLDER, optFolderId);
        }
        if (sortBy != null) {
            req.addAttribute(MailConstants.A_SORTBY, sortBy.name());
        }
        if (sync) {
            req.addAttribute(MailConstants.A_SYNC, sync);
        }

        if (attrs != null) {
            for (String name : attrs) {
                req.addElement(MailConstants.E_ATTRIBUTE).addAttribute(MailConstants.A_ATTRIBUTE_NAME, name);
            }
        }

        Element response = invoke(req);
        List<ZContact> result = new ArrayList<ZContact>();
        for (Element cn : response.listElements(MailConstants.E_CONTACT)) {
            result.add(new ZContact(cn, this));
        }
        return result;
    }

    /**
     * Specifies properties for an attachment when creating or modifying
     * a contact.
     */
    public static class ZAttachmentInfo {
        private String mAttachmentId;
        private String mPartName;
        private String mItemId;

        public ZAttachmentInfo setAttachmentId(String attachmentId) {
            mAttachmentId = attachmentId;
            return this;
        }

        public ZAttachmentInfo setPartName(String partName) {
            mPartName = partName;
            return this;
        }

        public ZAttachmentInfo setItemId(String itemId) {
            mItemId = itemId;
            return this;
        }

        public String getAttachmentId() {
            return mAttachmentId;
        }

        public String getPartName() {
            return mPartName;
        }

        public String getItemId() {
            return mItemId;
        }

        @Override
        public String toString() {
            return String.format("%s: {attachmentId=%s, partName=%s, itemId=%s}",
                    ZAttachmentInfo.class.getSimpleName(), mAttachmentId, mPartName, mItemId);
        }
    }

    /**
     * Creates a new contact.
     * @param folderId the new contact's folder id
     * @param tags tags to set on the contact, or <tt>null</tt>
     * @param attrs contact attributes (key/value)
     * @return the new contact
     * @throws ServiceException
     */
    public ZContact createContact(String folderId, String tags, Map<String, String> attrs) throws ServiceException {
        return createContact(folderId, tags, attrs, null, null);
    }

    /**
     * Creates a new contact.
     * @param folderId the new contact's folder id
     * @param tags tags to set on the contact, or <tt>null</tt>
     * @param attrs contact attributes (key/value)
     * @param members members of a contact group
     * @return the new contact
     * @throws ServiceException
     */
    public ZContact createContactWithMembers(String folderId, String tags, Map<String, String> attrs,
            Map<String, String> members) throws ServiceException {
        return createContact(folderId, tags, attrs, null, members);
    }

    public ZContact createContact(String folderId, String tags, Map<String, String> attrs,
            Map<String, ZAttachmentInfo> attachments) throws ServiceException {
        return createContact(folderId, tags, attrs, attachments, null);
    }

    /**
     * Creates a new contact.
     * @param folderId the new contact's folder id
     * @param tags tags to set on the contact, or <tt>null</tt>
     * @param attrs contact attributes (key/value)
     * @param attachments contact attachments (key/upload id) or <tt>null</tt>
     * @param verbose <tt>false</tt> to only initialize the <tt>id</tt> of the return <tt>ZContact</tt> object
     * @return the new contact
     * @throws ServiceException
     */
    public ZContact createContact(String folderId, String tags, Map<String, String> attrs,
            Map<String, ZAttachmentInfo> attachments, Map<String, String> members) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_CONTACT_REQUEST);
        Element cn = req.addUniqueElement(MailConstants.E_CONTACT);
        if (folderId != null) {
            cn.addAttribute(MailConstants.A_FOLDER, folderId);
        }
        if (tags != null) {
            cn.addAttribute(MailConstants.A_TAGS, tags);
        }
        addAttrsAndAttachments(cn, attrs, attachments);
        if (members != null) {
            for (Map.Entry<String, String> entry : members.entrySet()) {
                Element memberEl = cn.addElement(MailConstants.E_CONTACT_GROUP_MEMBER);
                memberEl.addAttribute(MailConstants.A_CONTACT_GROUP_MEMBER_VALUE, entry.getKey());
                memberEl.addAttribute(MailConstants.A_CONTACT_GROUP_MEMBER_TYPE, entry.getValue());
            }
        }

        return new ZContact(invoke(req).getElement(MailConstants.E_CONTACT), this);
    }

    private void addAttrsAndAttachments(Element cn, Map<String, String> attrs,
            Map<String, ZAttachmentInfo> attachments) {
        if (attrs != null) {
            for (Map.Entry<String, String> entry : attrs.entrySet()) {
                cn.addKeyValuePair(entry.getKey(), entry.getValue().trim(), MailConstants.E_ATTRIBUTE,
                        MailConstants.A_ATTRIBUTE_NAME);
            }
        }
        if (attachments != null) {
            for (String name : attachments.keySet()) {
                ZAttachmentInfo info = attachments.get(name);

                Element attachEl = cn.addElement(MailConstants.E_ATTRIBUTE);
                attachEl.addAttribute(MailConstants.A_ATTRIBUTE_NAME, name);
                if (info.getAttachmentId() != null) {
                    attachEl.addAttribute(MailConstants.A_ATTACHMENT_ID, info.getAttachmentId());
                } else if (info.getItemId() != null) {
                    attachEl.addAttribute(MailConstants.A_ID, info.getItemId());
                    attachEl.addAttribute(MailConstants.A_PART, info.getPartName());
                } else if (info.getPartName() != null) {
                    attachEl.addAttribute(MailConstants.A_PART, info.getPartName());
                }
            }
        }
    }

    /**
     * @param id of contact
     * @param replace if true, replace all attrs with specified attrs, otherwise merge with existing
     * @param attrs modified attrs
     * @param members members of a contact group
     * @return updated contact
     * @throws ServiceException on error
     */
    public ZContact modifyContactWithMembers(String id, boolean replace, Map<String, String> attrs,
            Map<String, String> members) throws ServiceException {
        return modifyContact(id, replace, attrs, null, members);
    }

    /**
     * @param id of contact
     * @param replace if true, replace all attrs with specified attrs, otherwise merge with existing
     * @param attrs modified attrs
     * @return updated contact
     * @throws ServiceException on error
     */
    public ZContact modifyContact(String id, boolean replace, Map<String, String> attrs) throws ServiceException {
        return modifyContact(id, replace, attrs, null, null);
    }

    /**
     * @param id of contact
     * @param replace if true, replace all attrs with specified attrs, otherwise merge with existing
     * @param attrs modified attrs
     * @param attachments contact attachments (key/upload id) or <tt>null</tt>
     * @return updated contact
     * @throws ServiceException on error
     */
    public ZContact modifyContact(String id, boolean replace, Map<String, String> attrs,
            Map<String, ZAttachmentInfo> attachments) throws ServiceException {
        return modifyContact(id, replace, attrs, attachments, null);
    }

    /**
     * @param id of contact
     * @param replace if true, replace all attrs with specified attrs, otherwise merge with existing
     * @param attrs modified attrs, or <tt>null</tt>
     * @param attachments modified attachments , or <tt>null</tt>
     * @param members members of a contact group
     * @return updated contact
     * @throws ServiceException on error
     */
    public ZContact modifyContact(String id, boolean replace, Map<String, String> attrs,
            Map<String, ZAttachmentInfo> attachments, Map<String, String> members) throws ServiceException {
        Element req = newRequestElement(MailConstants.MODIFY_CONTACT_REQUEST);
        if (replace) {
            req.addAttribute(MailConstants.A_REPLACE, replace);
        }
        Element cn = req.addUniqueElement(MailConstants.E_CONTACT);
        cn.addAttribute(MailConstants.A_ID, id);
        addAttrsAndAttachments(cn, attrs, attachments);
        if (members != null) {
            for (Map.Entry<String, String> entry : members.entrySet()) {
                Element memberEl = cn.addElement(MailConstants.E_CONTACT_GROUP_MEMBER);
                memberEl.addAttribute(MailConstants.A_CONTACT_GROUP_MEMBER_VALUE, entry.getKey());
                memberEl.addAttribute(MailConstants.A_CONTACT_GROUP_MEMBER_TYPE, entry.getValue());
            }
        }
        return new ZContact(invoke(req).getElement(MailConstants.E_CONTACT), this);
    }

    /**
     * Fetch contacts for a given folder or contacts ids
     * @param folderid folder id of the contact folder
     * @param ids comma-separated list of contact ids
     * @param attrs limit attrs returns to given list
     * @param sortBy sort results (null for no sorting)
     * @param sync if true, return modified date on contacts
     * @return list of contacts
     * @throws ServiceException on error
     */
    public List<ZContact> getContactsForFolder(String folderid, String ids, ContactSortBy sortBy, boolean sync,
            List<String> attrs) throws ServiceException {
        Element req = newRequestElement(MailConstants.GET_CONTACTS_REQUEST);

        if (!StringUtil.isNullOrEmpty(folderid)) {
            req.addAttribute(MailConstants.A_FOLDER, folderid);
        }
        if (sortBy != null) {
            req.addAttribute(MailConstants.A_SORTBY, sortBy.name());
        }
        if (sync) {
            req.addAttribute(MailConstants.A_SYNC, sync);
        }
        if (!StringUtil.isNullOrEmpty(ids)) {
            req.addAttribute(MailConstants.A_DEREF_CONTACT_GROUP_MEMBER, true);
            req.addElement(MailConstants.E_CONTACT).addAttribute(MailConstants.A_ID, ids);
        }
        if (attrs != null) {
            for (String name : attrs) {
                req.addElement(MailConstants.E_ATTRIBUTE).addAttribute(MailConstants.A_ATTRIBUTE_NAME, name);
            }
        }
        List<ZContact> result = new ArrayList<ZContact>();
        for (Element cn : invoke(req).listElements(MailConstants.E_CONTACT)) {
            result.add(new ZContact(cn, this));
        }
        return result;
    }

    public List<ZContact> getContacts(String ids, ContactSortBy sortBy, boolean sync, List<String> attrs)
            throws ServiceException {
        return getContactsForFolder(null, ids, sortBy, sync, attrs);
    }

    /**
     *
     * @param id single contact id to fetch
     * @return fetched contact
     * @throws ServiceException on error
     */
    public synchronized ZContact getContact(String id) throws ServiceException {
        ZContact result = mContactCache.get(id);
        if (result == null || result.isDirty()) {
            Element req = newRequestElement(MailConstants.GET_CONTACTS_REQUEST);
            req.addAttribute(MailConstants.A_SYNC, true);
            req.addElement(MailConstants.E_CONTACT).addAttribute(MailConstants.A_ID, id);
            req.addAttribute(MailConstants.A_DEREF_CONTACT_GROUP_MEMBER, true);
            result = new ZContact(invoke(req).getElement(MailConstants.E_CONTACT), this);
            mContactCache.put(id, result);
        }
        return result;
    }

    public synchronized ZContact getContactFromCache(String id) {
        return mContactCache.get(id);
    }

    public synchronized List<ZAutoCompleteMatch> autoComplete(String query, int limit) throws ServiceException {
        Element req = newRequestElement(MailConstants.AUTO_COMPLETE_REQUEST);
        req.addAttribute(MailConstants.A_LIMIT, limit);
        req.addAttribute(MailConstants.A_INCLUDE_GAL, getFeatures().getGalAutoComplete());
        req.addUniqueElement(MailConstants.E_NAME).setText(query);
        Element response = invoke(req);
        List<ZAutoCompleteMatch> matches = new ArrayList<ZAutoCompleteMatch>();
        for (Element match : response.listElements(MailConstants.E_MATCH)) {
            matches.add(new ZAutoCompleteMatch(match, this));
        }
        return matches;
    }

    private Element contactAction(String op, String id) {
        Element req = newRequestElement(MailConstants.CONTACT_ACTION_REQUEST);
        Element actionEl = req.addUniqueElement(MailConstants.E_ACTION);
        actionEl.addAttribute(MailConstants.A_ID, id);
        actionEl.addAttribute(MailConstants.A_OPERATION, op);
        return actionEl;
    }

    public ZActionResult moveContact(String ids, String destFolderId) throws ServiceException {
        return doAction(contactAction("move", ids).addAttribute(MailConstants.A_FOLDER, destFolderId));
    }

    public ZActionResult deleteContact(String ids) throws ServiceException {
        return doAction(contactAction("delete", ids));
    }

    public ZActionResult trashContact(String ids) throws ServiceException {
        return doAction(contactAction("trash", ids));
    }

    public ZActionResult flagContact(String ids, boolean flag) throws ServiceException {
        return doAction(contactAction(flag ? "flag" : "!flag", ids));
    }

    public ZActionResult tagContact(String ids, String tagId, boolean tag) throws ServiceException {
        return doAction(contactAction(tag ? "tag" : "!tag", ids).addAttribute(MailConstants.A_TAG, tagId));
    }

    @Deprecated
    public synchronized ZContact getMyCard() {
        return null;
    }

    @Deprecated
    public boolean getIsMyCard(String ids) {
        return false;
    }

    /**
     * update items(s)
     * @param ids list of contact ids to update
     * @param destFolderId optional destination folder
     * @param tagList optional new list of tag ids
     * @param flags optional new value for flags
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult updateContact(String ids, String destFolderId, String tagList, String flags)
            throws ServiceException {
        Element actionEl = contactAction("update", ids);
        if (destFolderId != null && destFolderId.length() > 0) {
            actionEl.addAttribute(MailConstants.A_FOLDER, destFolderId);
        }
        if (tagList != null) {
            actionEl.addAttribute(MailConstants.A_TAGS, tagList);
        }
        if (flags != null) {
            actionEl.addAttribute(MailConstants.A_FLAGS, flags);
        }
        return doAction(actionEl);
    }

    public static class ZImportContactsResult {

        private final String mIds;
        private final long mCount;

        public ZImportContactsResult(Element response) throws ServiceException {
            mIds = response.getAttribute(MailConstants.A_ID, null);
            mCount = response.getAttributeLong(MailConstants.A_NUM);
        }

        public ZImportContactsResult(ImportContactsResponse res) {
            ImportContact impCntct = res.getContact();
            mIds = impCntct.getListOfCreatedIds();
            mCount = impCntct.getNumImported();
        }

        public String getIds() {
            return mIds;
        }

        public long getCount() {
            return mCount;
        }
    }

    public static final String CONTACT_IMPORT_TYPE_CSV = "csv";

    public ZImportContactsResult importContacts(String folderId, String type, String attachmentId)
            throws ServiceException {
        ImportContactsRequest request = new ImportContactsRequest();
        request.setContentType(type);
        request.setFolderId(folderId);
        Content importContent = new Content();
        importContent.setAttachUploadId(attachmentId);
        request.setContent(importContent);
        ImportContactsResponse res = this.invokeJaxb(request);
        return new ZImportContactsResult(res);
    }

    /**
     *
     * @param id conversation id
     * @param fetch Whether or not fetch none/first/all messages in conv.
     * @return conversation
     * @throws ServiceException on error
     */
    public ZConversation getConversation(String id, Fetch fetch) throws ServiceException {
        Element req = newRequestElement(MailConstants.GET_CONV_REQUEST);
        Element convEl = req.addUniqueElement(MailConstants.E_CONV);
        convEl.addAttribute(MailConstants.A_ID, id);
        if (fetch != null && fetch != Fetch.none && fetch != Fetch.hits) {
            // use "1" for "first" for backward compat until DF is updated
            convEl.addAttribute(MailConstants.A_FETCH, fetch == Fetch.first ? "1" : fetch.name());
        }
        return new ZConversation(invoke(req).getElement(MailConstants.E_CONV), this);
    }

    /** include items in the Trash folder */
    public static final String TC_INCLUDE_TRASH = "t";

    /** include items in the Spam/Junk folder */
    public static final String TC_INCLUDE_JUNK = "j";

    /** include items in the Sent folder */
    public static final String TC_INCLUDE_SENT = "s";

    /** include items in any other folder */
    public static final String TC_INCLUDE_OTHER = "o";

    private Element convAction(String op, String id, String constraints) {
        Element req = newRequestElement(MailConstants.CONV_ACTION_REQUEST);
        Element actionEl = req.addUniqueElement(MailConstants.E_ACTION);
        actionEl.addAttribute(MailConstants.A_ID, id);
        actionEl.addAttribute(MailConstants.A_OPERATION, op);
        if (constraints != null) {
            actionEl.addAttribute(MailConstants.A_TARGET_CONSTRAINT, constraints);
        }
        return actionEl;
    }

    /**
     * hard delete conversation(s).
     *
     * @param ids list of conversation ids to act on
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items in a conversation. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult deleteConversation(String ids, String targetConstraints) throws ServiceException {
        return doAction(convAction("delete", ids, targetConstraints));
    }

    /**
     * moves conversation to trash folder.
     *
     * @param ids list of conversation ids to act on
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items in a conversation. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult trashConversation(String ids, String targetConstraints) throws ServiceException {
        return doAction(convAction("trash", ids, targetConstraints));
    }

    /**
     * mark conversation as read/unread
     *
     * @param ids list of conversation ids to act on
     * @param read mark read (TRUE) or unread (FALSE)
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items in a conversation. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult markConversationRead(String ids, boolean read, String targetConstraints)
            throws ServiceException {
        return doAction(convAction(read ? "read" : "!read", ids, targetConstraints));
    }

    /**
     * flag/unflag conversations
     *
     * @param ids list of conversation ids to act on
     * @param flag flag (TRUE) or unflag (FALSE)
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items in a conversation. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult flagConversation(String ids, boolean flag, String targetConstraints)
            throws ServiceException {
        return doAction(convAction(flag ? "flag" : "!flag", ids, targetConstraints));
    }

    /**
     * tag/untag conversations
     *
     * @param ids list of conversation ids to act on
     * @param tagId id of tag to tag/untag with
     * @param tag tag (TRUE) or untag (FALSE)
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items in a conversation. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult tagConversation(String ids, String tagId, boolean tag, String targetConstraints)
            throws ServiceException {
        return doAction(
                convAction(tag ? "tag" : "!tag", ids, targetConstraints).addAttribute(MailConstants.A_TAG, tagId));
    }

    /**
     * move conversations
     *
     * @param ids list of conversation ids to act on
     * @param destFolderId id of destination folder
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items in a conversation. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult moveConversation(String ids, String destFolderId, String targetConstraints)
            throws ServiceException {
        return doAction(
                convAction("move", ids, targetConstraints).addAttribute(MailConstants.A_FOLDER, destFolderId));
    }

    /**
     * spam/unspam a single conversation
     *
     * @param id conversation id to act on
     * @param spam spam (TRUE) or not spam (FALSE)
     * @param destFolderId optional id of destination folder, only used with "not spam".
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items in a conversation. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult markConversationSpam(String id, boolean spam, String destFolderId,
            String targetConstraints) throws ServiceException {
        Element actionEl = convAction(spam ? "spam" : "!spam", id, targetConstraints);
        if (destFolderId != null && destFolderId.length() > 0) {
            actionEl.addAttribute(MailConstants.A_FOLDER, destFolderId);
        }
        return doAction(actionEl);
    }

    private Element messageAction(String op, String id) {
        Element req = newRequestElement(MailConstants.MSG_ACTION_REQUEST);
        Element actionEl = req.addUniqueElement(MailConstants.E_ACTION);
        actionEl.addAttribute(MailConstants.A_ID, id);
        actionEl.addAttribute(MailConstants.A_OPERATION, op);
        return actionEl;
    }

    // ------------------------

    private Element itemAction(String op, String id, String constraints) {
        Element req = newRequestElement(MailConstants.ITEM_ACTION_REQUEST);
        Element actionEl = req.addUniqueElement(MailConstants.E_ACTION);
        actionEl.addAttribute(MailConstants.A_ID, id);
        actionEl.addAttribute(MailConstants.A_OPERATION, op);
        if (constraints != null) {
            actionEl.addAttribute(MailConstants.A_TARGET_CONSTRAINT, constraints);
        }
        return actionEl;
    }

    /**
     * hard delete item(s).
     *
     * @param ids list of item ids to act on
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult deleteItem(String ids, String targetConstraints) throws ServiceException {
        return doAction(itemAction("delete", ids, targetConstraints));
    }

    /**
     * permanently delete item(s) from the dumpster
     *
     * @param ids list of item ids to act on
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult dumpsterDeleteItem(String ids) throws ServiceException {
        return doAction(itemAction("dumpsterdelete", ids, null));
    }

    /**
     * move item(s) to trash
     *
     * @param ids list of item ids to act on
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult trashItem(String ids, String targetConstraints) throws ServiceException {
        return doAction(itemAction("trash", ids, targetConstraints));
    }

    /**
     * mark item as read/unread
     *
     * @param ids list of ids to act on
     * @param read mark read (TRUE) or unread (FALSE)
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult markItemRead(String ids, boolean read, String targetConstraints) throws ServiceException {
        return doAction(itemAction(read ? "read" : "!read", ids, targetConstraints));
    }

    /**
     * flag/unflag items
     *
     * @param ids list of ids to act on
     * @param flag flag (TRUE) or unflag (FALSE)
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult flagItem(String ids, boolean flag, String targetConstraints) throws ServiceException {
        return doAction(itemAction(flag ? "flag" : "!flag", ids, targetConstraints));
    }

    /**
     * tag/untag items
     *
     * @param ids list of ids to act on
     * @param tagId id of tag to tag/untag with
     * @param tag tag (TRUE) or untag (FALSE)
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items. A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult tagItem(String ids, String tagId, boolean tag, String targetConstraints)
            throws ServiceException {
        return doAction(
                itemAction(tag ? "tag" : "!tag", ids, targetConstraints).addAttribute(MailConstants.A_TAG, tagId));
    }

    /**
     * move conversations
     *
     * @param ids list of item ids to act on
     * @param destFolderId id of destination folder
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult moveItem(String ids, String destFolderId, String targetConstraints)
            throws ServiceException {
        return doAction(
                itemAction("move", ids, targetConstraints).addAttribute(MailConstants.A_FOLDER, destFolderId));
    }

    /**
     * update items(s)
     * @param ids list of items to act on
     * @param destFolderId optional destination folder
     * @param tagList optional new list of tag ids
     * @param flags optional new value for flags
     * @param targetConstraints list of characters comprised of TC_INCLUDE_* strings. Constrains the set of
     *         affected items A leading '-' means to negate the constraint(s). Use null for
     *         no constraints.
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult updateItem(String ids, String destFolderId, String tagList, String flags,
            String targetConstraints) throws ServiceException {
        Element actionEl = itemAction("update", ids, targetConstraints);
        if (destFolderId != null && destFolderId.length() > 0) {
            actionEl.addAttribute(MailConstants.A_FOLDER, destFolderId);
        }
        if (tagList != null) {
            actionEl.addAttribute(MailConstants.A_TAGS, tagList);
        }
        if (flags != null) {
            actionEl.addAttribute(MailConstants.A_FLAGS, flags);
        }
        return doAction(actionEl);
    }

    /**
     * recover items from the dumpster to the specified folder
     *
     * @param ids list of item ids to act on
     * @param destFolderId id of destination folder
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult recoverItem(String ids, String destFolderId) throws ServiceException {
        return doAction(itemAction("recover", ids, null).addAttribute(MailConstants.A_FOLDER, destFolderId));
    }

    /* ------------------------------------------------- */

    /**
     * Uploads files to <tt>FileUploadServlet</tt>.
     * @return the attachment id
     */
    public String uploadAttachments(File[] files, int msTimeout) throws ServiceException {
        Part[] parts = new Part[files.length];
        for (int i = 0; i < files.length; i++) {
            File file = files[i];
            String contentType = URLConnection.getFileNameMap().getContentTypeFor(file.getName());
            try {
                parts[i] = new FilePart(file.getName(), file, contentType, "UTF-8");
            } catch (IOException e) {
                throw ZClientException.IO_ERROR(e.getMessage(), e);
            }
        }

        return uploadAttachments(parts, msTimeout);
    }

    /**
     * Uploads a byte array to <tt>FileUploadServlet</tt>.
     * @return the attachment id
     */
    public String uploadAttachment(String name, byte[] content, String contentType, int msTimeout)
            throws ServiceException {
        FilePart part = new FilePart(name, new ByteArrayPartSource(name, content));
        part.setContentType(contentType);

        return uploadAttachments(new Part[] { part }, msTimeout);
    }

    /**
     * Uploads multiple byte arrays to <tt>FileUploadServlet</tt>.
     * @param attachments the attachments.  The key to the <tt>Map</tt> is the attachment
     * name and the value is the content.
     * @return the attachment id
     */
    public String uploadAttachments(Map<String, byte[]> attachments, int msTimeout) throws ServiceException {
        if (attachments == null || attachments.size() == 0) {
            return null;
        }
        Part[] parts = new Part[attachments.size()];
        int i = 0;
        for (String name : attachments.keySet()) {
            byte[] content = attachments.get(name);
            parts[i++] = createAttachmentPart(name, content);
        }

        return uploadAttachments(parts, msTimeout);
    }

    /**
     * Creates an <tt>HttpClient FilePart</tt> from the given filename and content.
     */
    public FilePart createAttachmentPart(String filename, byte[] content) {
        FilePart part = new FilePart(filename, new ByteArrayPartSource(filename, content));
        String contentType = URLConnection.getFileNameMap().getContentTypeFor(filename);
        part.setContentType(contentType);
        return part;
    }

    /**
     * Uploads HTTP post parts to <tt>FileUploadServlet</tt>.
     * @return the attachment id
     */
    public String uploadAttachments(Part[] parts, int msTimeout) throws ServiceException {
        String aid = null;

        URI uri = getUploadURI();
        HttpClient client = getHttpClient(uri);

        // make the post
        PostMethod post = new PostMethod(uri.toString());
        post.getParams().setSoTimeout(msTimeout);

        int statusCode;
        try {
            if (mCsrfToken != null) {
                post.setRequestHeader(Constants.CSRF_TOKEN, mCsrfToken);
            }
            post.setRequestEntity(new MultipartRequestEntity(parts, post.getParams()));
            statusCode = HttpClientUtil.executeMethod(client, post);

            // parse the response
            if (statusCode == HttpServletResponse.SC_OK) {
                String response = post.getResponseBodyAsString();
                aid = getAttachmentId(response);
            } else if (statusCode == HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE) {
                throw ZClientException.UPLOAD_SIZE_LIMIT_EXCEEDED("upload size limit exceeded", null);
            } else {
                throw ZClientException.UPLOAD_FAILED("Attachment post failed, status=" + statusCode, null);
            }
        } catch (IOException e) {
            throw ZClientException.IO_ERROR(e.getMessage(), e);
        } finally {
            post.releaseConnection();
        }
        return aid;
    }

    public String uploadContentAsStream(String name, InputStream in, String contentType, long contentLength,
            int msTimeout) throws ServiceException {
        return uploadContentAsStream(name, in, contentType, contentLength, msTimeout, false);
    }

    public String uploadContentAsStream(String name, InputStream in, String contentType, long contentLength,
            int msTimeout, boolean limitByFileUploadMaxSize) throws ServiceException {
        String aid = null;
        if (name != null) {
            contentType += "; name=" + name;
        }

        URI uri = getUploadURI(limitByFileUploadMaxSize);
        HttpClient client = getHttpClient(uri);

        // make the post
        PostMethod post = new PostMethod(uri.toString());
        post.getParams().setSoTimeout(msTimeout);

        int statusCode;
        try {
            post = HttpClientUtil.addInputStreamToHttpMethod(post, in, contentLength, contentType);
            if (mCsrfToken != null) {
                post.addRequestHeader(Constants.CSRF_TOKEN, this.mCsrfToken);
            }
            statusCode = HttpClientUtil.executeMethod(client, post);

            // parse the response
            if (statusCode == HttpServletResponse.SC_OK) {
                String response = post.getResponseBodyAsString();
                aid = getAttachmentId(response);
            } else if (statusCode == HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE) {
                throw ZClientException.UPLOAD_SIZE_LIMIT_EXCEEDED("upload size limit exceeded", null);
            } else {
                throw ZClientException.UPLOAD_FAILED("Attachment post failed, status=" + statusCode, null);
            }
        } catch (IOException e) {
            throw ZClientException.IO_ERROR(e.getMessage(), e);
        } finally {
            post.releaseConnection();
        }
        return aid;
    }

    public URI getUploadURI() throws ServiceException {
        return getUploadURI(false);
    }

    private URI getUploadURI(boolean limitByFileUploadMaxSize) throws ServiceException {
        try {
            URI uri = new URI(mTransport.getURI());
            return uri.resolve("/service/upload?fmt=raw" + (limitByFileUploadMaxSize ? "&lbfums" : ""));
        } catch (URISyntaxException e) {
            throw ZClientException.CLIENT_ERROR("unable to parse URI: " + mTransport.getURI(), e);
        }
    }

    private static Pattern sAttachmentId = Pattern.compile("\\d+,'.*','(.*)'");

    public static String getAttachmentId(String result) throws ZClientException {
        if (result.startsWith(HttpServletResponse.SC_OK + "")) {
            Matcher m = sAttachmentId.matcher(result);
            return m.find() ? m.group(1) : null;
        } else if (result.startsWith(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE + "")) {
            throw ZClientException.UPLOAD_SIZE_LIMIT_EXCEEDED("upload size limit exceeded", null);
        }
        throw ZClientException.UPLOAD_FAILED("upload failed, response: " + result, null);
    }

    public HttpClient getHttpClient(URI uri) {
        boolean isAdmin = uri.getPort() == LC.zimbra_admin_service_port.intValue();
        HttpState initialState = HttpClientUtil.newHttpState(getAuthToken(), uri.getHost(), isAdmin);
        HttpClient client = ZimbraHttpConnectionManager.getInternalHttpConnMgr().newHttpClient();
        client.setState(initialState);
        client.getParams().setCookiePolicy(CookiePolicy.BROWSER_COMPATIBILITY);
        return client;
    }

    /**
     * @param folderId (required) folderId of folder to add message to
     * @param flags non-comma-separated list of flags, e.g. "sf" for "sent by me and flagged",
     *        or <tt>null</tt>
     * @param tags coma-spearated list of tags, or null for no tags, or <tt>null</tt>
     * @param receivedDate time the message was originally received, in MILLISECONDS since the epoch,
     *        or <tt>0</tt> for the current time
     * @param content message content
     * @param noICal if TRUE, then don't process iCal attachments.
     * @return ID of newly created message
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public String addMessage(String folderId, String flags, String tags, long receivedDate, String content,
            boolean noICal) throws ServiceException {
        return addMessage(folderId, flags, tags, receivedDate, content, noICal, false);
    }

    /**
     * @param folderId (required) folderId of folder to add message to
     * @param flags non-comma-separated list of flags, e.g. "sf" for "sent by me and flagged",
     *        or <tt>null</tt>
     * @param tags coma-spearated list of tags, or null for no tags, or <tt>null</tt>
     * @param receivedDate time the message was originally received, in MILLISECONDS since the epoch,
     *        or <tt>0</tt> for the current time
     * @param content message content
     * @param noICal if TRUE, then don't process iCal attachments.
     * @param filterSent if TRUE, then do outgoing message filtering
     * @return ID of newly created message
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public String addMessage(String folderId, String flags, String tags, long receivedDate, String content,
            boolean noICal, boolean filterSent) throws ServiceException {
        Element req = newRequestElement(MailConstants.ADD_MSG_REQUEST);
        if (filterSent) {
            req.addAttribute(MailConstants.A_FILTER_SENT, filterSent);
        }
        Element m = req.addUniqueElement(MailConstants.E_MSG);
        m.addAttribute(MailConstants.A_FOLDER, folderId);
        if (flags != null && flags.length() > 0) {
            m.addAttribute(MailConstants.A_FLAGS, flags);
        }
        if (tags != null && tags.length() > 0) {
            m.addAttribute(MailConstants.A_TAGS, tags);
        }
        if (receivedDate != 0) {
            m.addAttribute(MailConstants.A_DATE, receivedDate);
        }
        m.addAttribute(MailConstants.A_NO_ICAL, noICal);
        m.addElement(MailConstants.E_CONTENT).setText(content);
        return invoke(req).getElement(MailConstants.E_MSG).getAttribute(MailConstants.A_ID);
    }

    /**
     * @param folderId (required) folderId of folder to add message to
     * @param flags non-comma-separated list of flags, e.g. "sf" for "sent by me and flagged",
     *        or <tt>null</tt>
     * @param tags comma-spearated list of tags, or null for no tags, or <tt>null</tt>
     * @param receivedDate time the message was originally received, in MILLISECONDS since the epoch,
     *        or <tt>0</tt> for the current time
     * @param content message content
     * @param noICal if TRUE, then don't process iCal attachments.
     * @return ID of newly created message
     * @throws ServiceException on error
     */
    public String addMessage(String folderId, String flags, String tags, long receivedDate, byte[] content,
            boolean noICal) throws ServiceException {
        return addMessage(folderId, flags, tags, receivedDate, new ByteArrayInputStream(content), content.length,
                noICal);
    }

    /**
     * @param folderId (required) folderId of folder to add message to
     * @param flags non-comma-separated list of flags, e.g. "sf" for "sent by me and flagged",
     *        or <tt>null</tt>
     * @param tags comma-spearated list of tags, or null for no tags, or <tt>null</tt>
     * @param receivedDate time the message was originally received, in MILLISECONDS since the epoch,
     *        or <tt>0</tt> for the current time
     * @param in content stream
     * @param contentLength number of bytes in the content stream
     * @param noICal if TRUE, then don't process iCal attachments.
     * @return ID of newly created message
     * @throws ServiceException on error
     */
    public String addMessage(String folderId, String flags, String tags, long receivedDate, InputStream in,
            long contentLength, boolean noICal) throws ServiceException {
        // first, upload the content via the FileUploadServlet
        String aid = uploadContentAsStream("message", in, "message/rfc822", contentLength, 5000);

        // now, use the returned upload ID to do the message send
        Element req = newRequestElement(MailConstants.ADD_MSG_REQUEST);
        Element m = req.addUniqueElement(MailConstants.E_MSG);
        m.addAttribute(MailConstants.A_FOLDER, folderId);
        if (flags != null && flags.length() > 0) {
            m.addAttribute(MailConstants.A_FLAGS, flags);
        }
        if (tags != null && tags.length() > 0) {
            m.addAttribute(MailConstants.A_TAGS, tags);
        }
        if (receivedDate > 0) {
            m.addAttribute(MailConstants.A_DATE, receivedDate);
        }
        m.addAttribute(MailConstants.A_ATTACHMENT_ID, aid);
        m.addAttribute(MailConstants.A_NO_ICAL, noICal);
        return invoke(req).getElement(MailConstants.E_MSG).getAttribute(MailConstants.A_ID);
    }

    static class CachedMessage {
        ZGetMessageParams params;
        ZMessage zm;
    }

    public synchronized ZMessage getMessage(ZGetMessageParams params) throws ServiceException {
        CachedMessage cm = mMessageCache.get(params.getId());
        if (cm == null || !cm.params.equals(params)) {
            Element req = newRequestElement(MailConstants.GET_MSG_REQUEST);
            Element msgEl = req.addUniqueElement(MailConstants.E_MSG);
            msgEl.addAttribute(MailConstants.A_ID, params.getId());
            if (params.getPart() != null) {
                msgEl.addAttribute(MailConstants.A_PART, params.getPart());
            }
            msgEl.addAttribute(MailConstants.A_MARK_READ, params.isMarkRead());
            msgEl.addAttribute(MailConstants.A_WANT_HTML, params.isWantHtml());
            msgEl.addAttribute(MailConstants.A_NEUTER, params.isNeuterImages());
            msgEl.addAttribute(MailConstants.A_RAW, params.isRawContent());
            if (params.getMax() != null) {
                msgEl.addAttribute(MailConstants.A_MAX_INLINED_LENGTH, params.getMax());
            }
            //header request bug:33054
            String reqHdrs = params.getReqHeaders();
            if (reqHdrs != null && reqHdrs.length() > 0) {
                for (String hdrName : reqHdrs.split(",")) {
                    Element headerEl = msgEl.addElement(MailConstants.A_HEADER);
                    headerEl.addAttribute(MailConstants.A_ATTRIBUTE_NAME, hdrName);
                }
            }
            ZMessage zm = new ZMessage(invoke(req).getElement(MailConstants.E_MSG), this);
            cm = new CachedMessage();
            cm.zm = zm;
            cm.params = params;
            mMessageCache.put(params.getId(), cm);
        } else {
            if (params.isMarkRead() && cm.zm.isUnread()) {
                markMessageRead(cm.zm.getId(), true);
            }
        }
        return cm.zm;
    }

    public synchronized ZMessage getMessageById(String id) throws ServiceException {
        ZGetMessageParams params = new ZGetMessageParams();
        params.setId(id);
        return getMessage(params);
    }

    /**
     * hard delete message(s)
     * @param ids ids to act on
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult deleteMessage(String ids) throws ServiceException {
        return doAction(messageAction("delete", ids));
    }

    /**
     * move message(s) to trash
     * @param ids ids to act on
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult trashMessage(String ids) throws ServiceException {
        return doAction(messageAction("trash", ids));
    }

    /**
     * mark message(s) as read/unread
     * @param ids ids to act on
     * @return action result
     * @throws ServiceException on error
     * @param read mark read/unread
     */
    public ZActionResult markMessageRead(String ids, boolean read) throws ServiceException {
        return doAction(messageAction(read ? "read" : "!read", ids));
    }

    /**
     *  mark message as spam/not spam
     * @param spam spam (TRUE) or not spam (FALSE)
     * @param id id of message
     * @param destFolderId optional id of destination folder, only used with "not spam".
     * @throws ServiceException on error
     * @return action result
     */
    public ZActionResult markMessageSpam(String id, boolean spam, String destFolderId) throws ServiceException {
        Element actionEl = messageAction(spam ? "spam" : "!spam", id);
        if (destFolderId != null && destFolderId.length() > 0) {
            actionEl.addAttribute(MailConstants.A_FOLDER, destFolderId);
        }
        return doAction(actionEl);
    }

    /** flag/unflag message(s)
     *
     * @return action result
     * @param ids of messages to flag
     * @param flag flag on /off
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZActionResult flagMessage(String ids, boolean flag) throws ServiceException {
        return doAction(messageAction(flag ? "flag" : "!flag", ids));
    }

    /** tag/untag message(s)
     * @param ids ids of messages to tag
     * @param tagId tag id to tag with
     * @param tag tag/untag
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult tagMessage(String ids, String tagId, boolean tag) throws ServiceException {
        return doAction(messageAction(tag ? "tag" : "!tag", ids).addAttribute(MailConstants.A_TAG, tagId));
    }

    /** move message(s)
     * @param ids list of ids to move
     * @param destFolderId destination folder id
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult moveMessage(String ids, String destFolderId) throws ServiceException {
        return doAction(messageAction("move", ids).addAttribute(MailConstants.A_FOLDER, destFolderId));
    }

    /**
     * update message(s)
     * @param ids ids of messages to update
     * @param destFolderId optional destination folder
     * @param tagList optional new list of tag ids
     * @param flags optional new value for flags
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult updateMessage(String ids, String destFolderId, String tagList, String flags)
            throws ServiceException {
        Element actionEl = messageAction("update", ids);
        if (destFolderId != null && destFolderId.length() > 0) {
            actionEl.addAttribute(MailConstants.A_FOLDER, destFolderId);
        }
        if (tagList != null) {
            actionEl.addAttribute(MailConstants.A_TAGS, tagList);
        }
        if (flags != null) {
            actionEl.addAttribute(MailConstants.A_FLAGS, flags);
        }
        return doAction(actionEl);
    }

    // ------------------------

    /**
     * return the root user folder
     * @return user root folder
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZFolder getUserRoot() throws ServiceException {
        populateFolderCache();
        return mUserRoot;
    }

    /**
     * find the folder with the specified path, starting from the user root.
     * @param path path of folder. Must start with {@link #PATH_SEPARATOR}.
     * @return ZFolder if found, null otherwise.
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZFolder getFolderByPath(String path) throws ServiceException {
        populateFolderCache();
        if (!path.startsWith(ZMailbox.PATH_SEPARATOR)) {
            path = ZMailbox.PATH_SEPARATOR + path;
        }
        if (mUserRoot == null) {
            return null;
        }
        return mUserRoot.getSubFolderByPath(path.substring(1));
    }

    public ZFolder getInbox() throws ServiceException {
        return getFolderById(ZFolder.ID_INBOX);
    }

    public ZFolder getTrash() throws ServiceException {
        return getFolderById(ZFolder.ID_TRASH);
    }

    public ZFolder getSpam() throws ServiceException {
        return getFolderById(ZFolder.ID_SPAM);
    }

    public ZFolder getJunk() throws ServiceException {
        return getFolderById(ZFolder.ID_SPAM);
    }

    public ZFolder getSent() throws ServiceException {
        return getFolderById(ZFolder.ID_SENT);
    }

    public ZFolder getDrafts() throws ServiceException {
        return getFolderById(ZFolder.ID_DRAFTS);
    }

    public ZFolder getContacts() throws ServiceException {
        return getFolderById(ZFolder.ID_CONTACTS);
    }

    public ZFolder getCalendar() throws ServiceException {
        return getFolderById(ZFolder.ID_CALENDAR);
    }

    public ZFolder getNotebok() throws ServiceException {
        return getFolderById(ZFolder.ID_NOTEBOOK);
    }

    public ZFolder getAutoContacts() throws ServiceException {
        return getFolderById(ZFolder.ID_AUTO_CONTACTS);
    }

    public ZFolder getChats() throws ServiceException {
        return getFolderById(ZFolder.ID_CHATS);
    }

    public ZFolder getTasks() throws ServiceException {
        return getFolderById(ZFolder.ID_TASKS);
    }

    public ZFolder getBriefcase() throws ServiceException {
        return getFolderById(ZFolder.ID_BRIEFCASE);
    }

    /**
     * find the folder with the specified id.
     * @param id id of  folder
     * @return ZFolder if found, null otherwise.
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZFolder getFolderById(String id) throws ServiceException {
        populateFolderCache();
        ZItem item = mItemCache.getById(id);
        if (!(item instanceof ZFolder)) {
            return null;
        }
        ZFolder folder = (ZFolder) item;
        return folder.isHierarchyPlaceholder() ? null : folder;
    }

    /**
     * find the folder with the specified UUID.
     * @param uuid UUID of folder
     * @return ZFolder if found, null otherwise.
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZFolder getFolderByUuid(String uuid) throws ServiceException {
        populateFolderCache();
        ZItem item = mItemCache.getByUuid(uuid);
        if (!(item instanceof ZFolder)) {
            return null;
        }
        ZFolder folder = (ZFolder) item;
        return folder.isHierarchyPlaceholder() ? null : folder;
    }

    /**
     * find the folder with the specified path/id. Look up by path first, then id if path not found.
     * @param pathOrId path or id of  folder
     * @return ZFolder if found, null otherwise.
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZFolder getFolder(String pathOrId) throws ServiceException {
        ZFolder result = getFolderByPath(pathOrId);
        return result != null ? result : getFolderById(pathOrId);
    }

    /**
     * always bypass caching and issues a GetFolderRequest
     *
     * @param id
     * @return
     * @throws ServiceException
     */
    public ZFolder getFolderRequestById(String id) throws ServiceException {
        Element req = newRequestElement(MailConstants.GET_FOLDER_REQUEST).addAttribute(MailConstants.A_VISIBLE,
                true);
        req.addElement(MailConstants.E_FOLDER).addAttribute(MailConstants.A_FOLDER, id);

        Element response = invoke(req);
        Element eFolder = response.getOptionalElement(MailConstants.E_FOLDER);
        if (eFolder == null) {
            eFolder = response.getOptionalElement(MailConstants.E_MOUNT);
        }
        if (eFolder == null) {
            return null;
        }

        ZFolder folder = new ZFolder(eFolder, null, this);
        return folder.isHierarchyPlaceholder() ? null : folder;
    }

    /**
     * to be backward compatible with YCal which currently calls this method.
     * should switch to getFolderRequestById
     *
     * delete this methods when it's time
     *
     * @param id
     * @return
     * @throws ServiceException
     */
    public ZFolder getFolderRequest(String id) throws ServiceException {
        return getFolderRequestById(id);
    }

    /**
     * Returns all folders and subfolders in this mailbox.
     * @throws ServiceException on error
     * @return all folders and subfolders in this mailbox
     */
    public List<ZFolder> getAllFolders() throws ServiceException {
        populateFolderCache();
        List<ZFolder> allFolders = new ArrayList<ZFolder>();
        if (getUserRoot() != null) {
            addSubFolders(getUserRoot(), allFolders);
        }
        return allFolders;
    }

    private void addSubFolders(ZFolder folder, List<ZFolder> folderList) throws ServiceException {
        if (!folder.isHierarchyPlaceholder()) {
            folderList.add(folder);
        }
        for (ZFolder subFolder : folder.getSubFolders()) {
            addSubFolders(subFolder, folderList);
        }
    }

    /**
     * returns a rest URL relative to this mailbox.
     * @param relativePath a relative path (i.e., "/Calendar", "Inbox?fmt=rss", etc).
     * @return URI of path
     * @throws ServiceException on error
     */
    public URI getRestURI(String relativePath) throws ServiceException {
        return getRestURI(relativePath, null);
    }

    /**
     * returns a rest URL relative to this mailbox.
     * @param relativePath a relative path (i.e., "/Calendar", "Inbox?fmt=rss", etc).
     * @param alternateUrl alternate url to connect to
     * @return URI of path
     * @throws ServiceException on error
     */
    private URI getRestURI(String relativePath, String alternateUrl) throws ServiceException {
        String pathPrefix = "/";
        if (relativePath.startsWith("/")) {
            pathPrefix = "";
        }

        try {
            String restURI = getAccountInfo(false).getRestURLBase();
            if (alternateUrl != null) {
                // parse the URI and extract path
                URI uri = new URI(restURI);
                restURI = alternateUrl + uri.getPath();
            }

            if (restURI == null) {
                URI uri = new URI(mTransport.getURI());
                return uri.resolve("/home/" + getName() + pathPrefix + relativePath);
            } else {
                return new URI(restURI + pathPrefix + relativePath);
            }
        } catch (URISyntaxException e) {
            throw ZClientException.CLIENT_ERROR("unable to parse URI: " + mTransport.getURI(), e);
        }
    }

    /**
     *
     * @param relativePath a relative path (i.e., "/Calendar", "Inbox?fmt=rss", etc).
     * @param os the stream to send the output to
     * @param closeOs whether or not to close the output stream when done
     * @param msecTimeout connection timeout
     * @param alternateUrl if not <tt>null</tt>, this URL will be used instead of
     * <tt>relativePath</tt>
     * @throws ServiceException on error
     */
    public void getRESTResource(String relativePath, OutputStream os, boolean closeOs, String startTimeArg,
            String endTimeArg, int msecTimeout, String alternateUrl) throws ServiceException {
        InputStream in = null;
        try {
            in = getRESTResource(relativePath, startTimeArg, endTimeArg, msecTimeout, alternateUrl);
            ByteUtil.copy(in, false, os, closeOs);
        } catch (IOException e) {
            throw ZClientException.IO_ERROR("Unable to get " + relativePath, e);
        } finally {
            ByteUtil.closeStream(in);
        }
    }

    private InputStream getRESTResource(String relativePath, String startTimeArg, String endTimeArg,
            int msecTimeout, String alternateUrl) throws ServiceException {
        GetMethod get = null;
        URI uri = null;

        int statusCode;
        try {
            if (startTimeArg != null) {
                String encodedArg = URLEncoder.encode(startTimeArg, "UTF-8");
                if (!relativePath.contains("?")) {
                    relativePath = relativePath + "?start=" + encodedArg;
                } else {
                    relativePath = relativePath + "&start=" + encodedArg;
                }
            }
            if (endTimeArg != null) {
                String encodedArg = URLEncoder.encode(endTimeArg, "UTF-8");
                if (!relativePath.contains("?")) {
                    relativePath = relativePath + "?end=" + encodedArg;
                } else {
                    relativePath = relativePath + "&end=" + encodedArg;
                }
            }

            uri = getRestURI(relativePath, alternateUrl);
            HttpClient client = getHttpClient(uri);

            get = new GetMethod(uri.toString());

            if (msecTimeout > -1) {
                get.getParams().setSoTimeout(msecTimeout);
            }

            statusCode = HttpClientUtil.executeMethod(client, get);
            // parse the response
            if (statusCode == HttpServletResponse.SC_OK) {
                return new GetMethodInputStream(get);
            } else {
                String msg = String.format("GET from %s failed, status=%d.  %s", uri.toString(), statusCode,
                        get.getStatusText());
                throw ServiceException.FAILURE(msg, null);
            }
        } catch (IOException e) {
            String fromUri = "";
            if (uri != null) {
                fromUri = " from " + uri.toString();
            }
            String msg = String.format("Unable to get REST resource%s: %s", fromUri, e.getMessage());
            throw ZClientException.IO_ERROR(msg, e);
        }
    }

    /**
     * @param relativePath a relative path (i.e., "/Calendar", "Inbox?fmt=rss", etc).
     * @throws ServiceException on error
     */
    public InputStream getRESTResource(String relativePath) throws ServiceException {
        return getRESTResource(relativePath, null, null, getTimeout(), null);
    }

    /**
     *
     * @param relativePath a relative path (i.e., "/Calendar", "Inbox?fmt=rss", etc).
     * @param is the input stream to post
     * @param closeIs whether to close the input stream when done
     * @param length length of inputstream, or 0/-1 if length is unknown.
     * @param contentType optional content-type header value (defaults to "application/octect-stream")
     * @param ignoreAndContinueOnError if true, set optional ignore=1 query string parameter
     * @param preserveAlarms if true, set optional preserveAlarms=1 query string parameter
     * @param msecTimeout connection timeout in milliseconds, or <tt>-1</tt> for no timeout
     * @param url alternate url to connect to
     * @throws ServiceException on error
     */
    public void postRESTResource(String relativePath, InputStream is, boolean closeIs, long length,
            String contentType, boolean ignoreAndContinueOnError, boolean preserveAlarms, int msecTimeout,
            String alternateUrl) throws ServiceException {
        PostMethod post = null;

        try {
            if (ignoreAndContinueOnError) {
                if (!relativePath.contains("?")) {
                    relativePath = relativePath + "?ignore=1";
                } else {
                    relativePath = relativePath + "&ignore=1";
                }
            }
            if (preserveAlarms) {
                if (!relativePath.contains("?")) {
                    relativePath = relativePath + "?preserveAlarms=1";
                } else {
                    relativePath = relativePath + "&preserveAlarms=1";
                }
            }
            URI uri = getRestURI(relativePath, alternateUrl);
            HttpClient client = getHttpClient(uri);

            post = new PostMethod(uri.toString());

            if (msecTimeout > -1) {
                post.getParams().setSoTimeout(msecTimeout);
            }

            post = HttpClientUtil.addInputStreamToHttpMethod(post, is, length,
                    contentType != null ? contentType : "application/octet-stream");
            int statusCode = HttpClientUtil.executeMethod(client, post);
            // parse the response
            if (statusCode == HttpServletResponse.SC_OK) {
                //
            } else {
                throw ServiceException.FAILURE("POST failed, status=" + statusCode + " " + post.getStatusText(),
                        null);
            }
        } catch (IOException e) {
            throw ZClientException.IO_ERROR(e.getMessage(), e);
        } finally {
            if (closeIs) {
                ByteUtil.closeStream(is);
            }
            if (post != null) {
                post.releaseConnection();
            }
        }
    }

    /**
     *
     * @param relativePath a relative path (i.e., "/Calendar", "Inbox?fmt=rss", etc).
     * @param is the input stream to post
     * @param closeIs whether to close the input stream when done
     * @param length length of inputstream, or 0/-1 if length is unknown.
     * @param contentType optional content-type header value (defaults to "application/octect-stream")
     * @param msecTimeout connection timeout in milliseconds, or <tt>-1</tt> for no timeout
     * @throws ServiceException on error
     */
    public void postRESTResource(String relativePath, InputStream is, boolean closeIs, long length,
            String contentType, int msecTimeout) throws ServiceException {
        postRESTResource(relativePath, is, closeIs, length, contentType, false, false, msecTimeout, null);
    }

    /**
     * find the search folder with the specified id.
     * @param id id of  folder
     * @return ZSearchFolder if found, null otherwise.
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZSearchFolder getSearchFolderById(String id) throws ServiceException {
        populateFolderCache();
        ZItem item = mItemCache.getById(id);
        if (item instanceof ZSearchFolder) {
            return (ZSearchFolder) item;
        } else {
            return null;
        }
    }

    /**
     * find the mountpoint with the specified id.
     * @param id id of mountpoint
     * @return ZMountpoint if found, null otherwise.
     * @throws com.zimbra.common.service.ServiceException on error
     */
    public ZMountpoint getMountpointById(String id) throws ServiceException {
        populateFolderCache();
        ZItem item = mItemCache.getById(id);
        if (item instanceof ZMountpoint) {
            return (ZMountpoint) item;
        } else {
            return null;
        }
    }

    /**
     * create a new sub folder of the specified parent folder.
     *
     * @param parentId parent folder id
     * @param name name of new folder
     * @param defaultView default view of new folder or null.
     * @param color color of folder, or null to use default
     * @param flags flags for folder, or null
     *
     * @return newly created folder
     * @throws ServiceException on error
     * @param url remote url for rss/atom/ics feeds
     */
    public ZFolder createFolder(String parentId, String name, ZFolder.View defaultView, ZFolder.Color color,
            String flags, String url) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_FOLDER_REQUEST);
        Element folderEl = req.addUniqueElement(MailConstants.E_FOLDER);
        folderEl.addAttribute(MailConstants.A_NAME, name);
        folderEl.addAttribute(MailConstants.A_FOLDER, parentId);
        if (defaultView != null) {
            folderEl.addAttribute(MailConstants.A_DEFAULT_VIEW, defaultView.name());
        }
        if (color != null) {
            if (StringUtil.equal(color.getName(), Color.RGBCOLOR)) {
                folderEl.addAttribute(MailConstants.A_RGB, color.getRgbColorValue());
            } else {
                folderEl.addAttribute(MailConstants.A_COLOR, color.getValue());
            }
        }
        if (flags != null) {
            folderEl.addAttribute(MailConstants.A_FLAGS, flags);
        }
        if (url != null && url.length() > 0) {
            folderEl.addAttribute(MailConstants.A_URL, url);
        }
        Element newFolderEl = invoke(req).getElement(MailConstants.E_FOLDER);
        ZFolder newFolder = getFolderById(newFolderEl.getAttribute(MailConstants.A_ID));
        return newFolder != null ? newFolder : new ZFolder(newFolderEl, null, this);
    }

    /**
     * create a new sub folder of the specified parent folder.
     *
     * @param parentId parent folder id
     * @param name name of new folder
     * @param query search query (required)
     * @param types comma-sep list of types to search for.  Use null for default value.
     * @param sortBy how to sort the result. Use null for default value.
     * @see {@link ZSearchParams#TYPE_MESSAGE}
     * @return newly created search folder
     * @throws ServiceException on error
     * @param color color of folder
     */
    public ZSearchFolder createSearchFolder(String parentId, String name, String query, String types,
            SearchSortBy sortBy, ZFolder.Color color) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_SEARCH_FOLDER_REQUEST);
        Element folderEl = req.addUniqueElement(MailConstants.E_SEARCH);
        folderEl.addAttribute(MailConstants.A_NAME, name);
        folderEl.addAttribute(MailConstants.A_FOLDER, parentId);
        folderEl.addAttribute(MailConstants.A_QUERY, query);
        if (color != null) {
            if (StringUtil.equal(color.getName(), Color.RGBCOLOR)) {
                folderEl.addAttribute(MailConstants.A_RGB, color.getRgbColorValue());
            } else {
                folderEl.addAttribute(MailConstants.A_COLOR, color.getValue());
            }
        }
        if (types != null) {
            folderEl.addAttribute(MailConstants.A_SEARCH_TYPES, types);
        }
        if (sortBy != null) {
            folderEl.addAttribute(MailConstants.A_SORTBY, sortBy.name());
        }
        Element newSearchEl = invoke(req).getElement(MailConstants.E_SEARCH);
        ZSearchFolder newSearch = getSearchFolderById(newSearchEl.getAttribute(MailConstants.A_ID));
        return newSearch != null ? newSearch : new ZSearchFolder(newSearchEl, null, this);
    }

    /**
     * modify a search folder.
     *
     * @param id id of search folder
     * @param query search query or null to leave unchanged.
     * @param types new types or null to leave unchanged.
     * @param sortBy new sortBy or null to leave unchanged
     * @return modified search folder
     * @throws ServiceException on error
     */
    public ZSearchFolder modifySearchFolder(String id, String query, String types, SearchSortBy sortBy)
            throws ServiceException {
        Element req = newRequestElement(MailConstants.MODIFY_SEARCH_FOLDER_REQUEST);
        Element folderEl = req.addUniqueElement(MailConstants.E_SEARCH);
        folderEl.addAttribute(MailConstants.A_ID, id);
        if (query != null) {
            folderEl.addAttribute(MailConstants.A_QUERY, query);
        }
        if (types != null) {
            folderEl.addAttribute(MailConstants.A_SEARCH_TYPES, types);
        }
        if (sortBy != null) {
            folderEl.addAttribute(MailConstants.A_SORTBY, sortBy.name());
        }
        invoke(req);
        // this assumes notifications will modify the search folder
        return getSearchFolderById(id);
    }

    public static class ZActionResult {
        private final String mIds;
        private final Element mResponse;

        public ZActionResult(Element response) throws ServiceException {
            String ids = response.getElement(MailConstants.E_ACTION).getAttribute(MailConstants.A_ID);
            if (ids == null) {
                ids = "";
            }
            mIds = ids;
            mResponse = response;
        }

        public String getIds() {
            return mIds;
        }

        public String[] getIdsAsArray() {
            return mIds.split(",");
        }

        @Override
        public String toString() {
            return String.format("[ZActionResult %s]", mIds);
        }

        Element getResponse() {
            return mResponse;
        }
    }

    private Element folderAction(String op, String ids) {
        Element req = newRequestElement(MailConstants.FOLDER_ACTION_REQUEST);
        Element actionEl = req.addUniqueElement(MailConstants.E_ACTION);
        actionEl.addAttribute(MailConstants.A_ID, ids);
        actionEl.addAttribute(MailConstants.A_OPERATION, op);
        return actionEl;
    }

    /** sets or unsets the folder's checked state in the UI
     * @param ids ids of folder to check
     * @param checked checked/unchecked
     * @throws ServiceException on error
     * @return action result
     */
    public ZActionResult modifyFolderChecked(String ids, boolean checked) throws ServiceException {
        return doAction(folderAction(checked ? "check" : "!check", ids));
    }

    /** modifies the folder's color
     * @param ids ids to modify
     * @param color new color
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult modifyFolderColor(String ids, ZFolder.Color color) throws ServiceException {
        return doAction(folderAction("color", ids).addAttribute(MailConstants.A_COLOR, color.getValue()));
    }

    /** hard delete the folder, all items in folder and all sub folders
     * @param ids ids to delete
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult deleteFolder(String ids) throws ServiceException {
        return doAction(folderAction("delete", ids));
    }

    /** move the folder to the Trash, marking all contents as read and
     * renaming the folder if a folder by that name is already present in the Trash
     * @param ids ids to delete
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult trashFolder(String ids) throws ServiceException {
        return doAction(folderAction("trash", ids));
    }

    /** hard delete all items in folder and sub folders (doesn't delete the folder itself)
     * @param ids ids of folders to empty
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult emptyFolder(String ids) throws ServiceException {
        return emptyFolder(ids, true);
    }

    /** hard delete all items in folder (doesn't delete the folder itself)
     *  deletes subfolders contained in the specified folder(s) if <tt>subfolders</tt> is set
     *
     * @param ids ids of folders to empty
     * @param subfolders whether to delete subfolders of this folder
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult emptyFolder(String ids, boolean subfolders) throws ServiceException {
        return doAction(folderAction("empty", ids).addAttribute(MailConstants.A_RECURSIVE, subfolders));
    }

    /** empties the dumpster
     *
     * @throws ServiceException
     */
    public void emptyDumpster() throws ServiceException {
        Element req = newRequestElement(MailConstants.EMPTY_DUMPSTER_REQUEST);
        invoke(req);
    }

    /** mark all items in folder as read
     * @param ids ids of folders to mark as read
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult markFolderRead(String ids) throws ServiceException {
        return doAction(folderAction("read", ids));
    }

    /** add the contents of the remote feed at target-url to the folder (one time action)
     * @param id of folder to import into
     * @param url url to import
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult importURLIntoFolder(String id, String url) throws ServiceException {
        return doAction(folderAction("import", id).addAttribute(MailConstants.A_URL, url));
    }

    /** move the folder to be a child of {target-folder}
     * @param id folder id to move
     * @param targetFolderId id of target folder
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult moveFolder(String id, String targetFolderId) throws ServiceException {
        return doAction(folderAction("move", id).addAttribute(MailConstants.A_FOLDER, targetFolderId));
    }

    /** change the folder's name; if new name  begins with '/', the folder is moved to the new path and any missing path elements are created
     * @param id id of folder to rename
     * @param name new name
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult renameFolder(String id, String name) throws ServiceException {
        return renameFolder(id, name, null);
    }

    /** changes the folder's name and moves it be a child of the given target folder.
     * @param id folder id
     * @param name new name
     * @param targetFolderId new parent, or <tt>null</tt> to keep the current parent
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult renameFolder(String id, String name, String targetFolderId) throws ServiceException {
        Element folderAction = folderAction("rename", id);
        folderAction.addAttribute(MailConstants.A_NAME, name);
        if (targetFolderId != null) {
            folderAction.addAttribute(MailConstants.A_FOLDER, targetFolderId);
        }
        return doAction(folderAction);
    }

    /** sets or unsets the folder's exclude from free busy state
     * @param ids folder id
     * @param state exclude/not-exclude
     * @throws ServiceException on error
     * @return action result
     */
    public ZActionResult modifyFolderExcludeFreeBusy(String ids, boolean state) throws ServiceException {
        return doAction(folderAction("fb", ids).addAttribute(MailConstants.A_EXCLUDE_FREEBUSY, state));
    }

    /**
     *
     * @param folderId to modify
     * @param granteeType type of grantee
     * @param grantreeId id of grantree
     * @param perms permission mask ("rwid")
     * @param args extra args
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult modifyFolderGrant(String folderId, GranteeType granteeType, String grantreeId,
            String perms, String args) throws ServiceException {
        Element action = folderAction("grant", folderId);
        Element grant = action.addUniqueElement(MailConstants.E_GRANT);
        grant.addAttribute(MailConstants.A_RIGHTS, perms);
        grant.addAttribute(MailConstants.A_DISPLAY, grantreeId);
        grant.addAttribute(MailConstants.A_GRANT_TYPE, granteeType.name());
        if (args != null) {
            if (granteeType == GranteeType.key) {
                grant.addAttribute(MailConstants.A_ACCESSKEY, args);
            } else {
                grant.addAttribute(MailConstants.A_ARGS, args);
            }
        }
        ZActionResult r = doAction(action);

        /*
         * for key grantee type, the accesskey is not encoded in the <notify>
         * block in FolderAction or the <refresh> block for any calls.
         * accesskey is only returned on explicit GetFolderRequest.
         *
         * add a convenient hack here so client does not have to call
         * mbox.getFolderRequest after a modifyFolderGrant in order to get
         * the (new or modified) accesskey.
         */
        if (granteeType == GranteeType.key) {
            ZFolder folder = getFolderById(folderId);
            for (ZGrant g : folder.getGrants()) {
                if (g.getGranteeType() == GranteeType.key && g.getGranteeId().equals(grantreeId)) {
                    String key = null;
                    Element eAction = r.getResponse().getOptionalElement(MailConstants.E_ACTION);
                    if (eAction != null) {
                        key = eAction.getAttribute(MailConstants.A_ACCESSKEY, null);
                    }
                    if (key != null) {
                        g.setAccessKey(key);
                    }
                    break;
                }
            }
        }

        return r;
    }

    /**
     * revoke a grant
     * @param folderId folder id to modify
     * @param grantreeId zimbra ID
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult modifyFolderRevokeGrant(String folderId, String grantreeId) throws ServiceException {
        Element action = folderAction("!grant", folderId);
        action.addAttribute(MailConstants.A_ZIMBRA_ID, grantreeId);
        return doAction(action);
    }

    /**
     * set the synchronization url on the folder to {target-url}, empty the folder, and
     * synchronize the folder's contents to the remote feed, also sets {exclude-free-busy-boolean}
     * @param id id of folder
     * @param url new URL
     * @return action result
     * @throws ServiceException on error
     */
    public ZActionResult modifyFolderURL(String id, String url) throws ServiceException {
        return doAction(folderAction("url", id).addAttribute(MailConstants.A_URL, url));
    }

    public ZActionResult updateFolder(String id, String name, String parentId, Color newColor, String rgbColor,
            String flags, List<ZGrant> acl) throws ServiceException {
        Element action = folderAction("update", id);
        if (name != null && name.length() > 0) {
            action.addAttribute(MailConstants.A_NAME, name);
        }
        if (parentId != null && parentId.length() > 0) {
            action.addAttribute(MailConstants.A_FOLDER, parentId);
        }
        if (newColor != null) {
            action.addAttribute(MailConstants.A_COLOR, newColor.getValue());
        }
        if (rgbColor != null) {
            action.addAttribute(MailConstants.A_RGB, rgbColor);
        }
        if (flags != null) {
            action.addAttribute(MailConstants.A_FLAGS, flags);
        }
        if (acl != null) {
            Element aclEl = action.addElement(MailConstants.E_ACL);
            for (ZGrant grant : acl) {
                grant.toElement(aclEl);
            }
        }
        return doAction(action);
    }

    /**
     * sync the folder's contents to the remote feed specified by the folders URL
     * @param ids folder id
     * @throws ServiceException on error
     * @return action result
     */
    public ZActionResult syncFolder(String ids) throws ServiceException {
        return doAction(folderAction("sync", ids));
    }

    /** sets or unsets the folder's sync flag
     * @param id folder id
     * @param syncon turn sync flag on
     * @throws ServiceException on error
     * @return action result
     */
    public ZActionResult modifyFolderSyncFlag(String id, boolean syncon) throws ServiceException {
        return doAction(folderAction(syncon ? "syncon" : "!syncon", id));
    }

    // ------------------------

    private synchronized ZSearchResult internalSearch(String convId, ZSearchParams params, boolean nest)
            throws ServiceException {
        QName name;
        if (convId != null) {
            name = MailConstants.SEARCH_CONV_REQUEST;
        } else if (params.getTypes().equals(ZSearchParams.TYPE_VOICE_MAIL)
                || params.getTypes().equals(ZSearchParams.TYPE_CALL)) {
            name = VoiceConstants.SEARCH_VOICE_REQUEST;
        } else if (params.getTypes().equals(ZSearchParams.TYPE_GAL)) {
            name = AccountConstants.SEARCH_GAL_REQUEST;
        } else {
            name = MailConstants.SEARCH_REQUEST;
        }

        Element req = newRequestElement(name);

        if (params.getTypes().equals(ZSearchParams.TYPE_GAL)) {
            req.addAttribute(AccountConstants.A_TYPE, GalEntryType.account.name());
            req.addElement(AccountConstants.E_NAME).setText(params.getQuery());
            //req.addAttribute(MailConstants.A_SORTBY, SearchSortBy.nameAsc.name());

        }

        req.addAttribute(MailConstants.A_CONV_ID, convId);
        if (nest) {
            req.addAttribute(MailConstants.A_NEST_MESSAGES, true);
        }
        if (params.getLimit() != 0) {
            req.addAttribute(MailConstants.A_QUERY_LIMIT, params.getLimit());
        }
        if (params.getOffset() != 0) {
            req.addAttribute(MailConstants.A_QUERY_OFFSET, params.getOffset());
        }
        if (params.getSortBy() != null) {
            req.addAttribute(MailConstants.A_SORTBY, params.getSortBy().name());
        }
        if (params.getTypes() != null) {
            req.addAttribute(MailConstants.A_SEARCH_TYPES, params.getTypes());
        }
        if (params.getFetch() != null && params.getFetch() != Fetch.none) {
            // use "1" for "first" for backward compat until DF is updated
            req.addAttribute(MailConstants.A_FETCH,
                    params.getFetch() == Fetch.first ? "1" : params.getFetch().name());
        }
        if (params.getCalExpandInstStart() != 0) {
            req.addAttribute(MailConstants.A_CAL_EXPAND_INST_START, params.getCalExpandInstStart());
        }
        if (params.getCalExpandInstEnd() != 0) {
            req.addAttribute(MailConstants.A_CAL_EXPAND_INST_END, params.getCalExpandInstEnd());
        }

        if (params.isPreferHtml()) {
            req.addAttribute(MailConstants.A_WANT_HTML, params.isPreferHtml());
        }
        if (params.isMarkAsRead()) {
            req.addAttribute(MailConstants.A_MARK_READ, params.isMarkAsRead());
        }
        if (params.isRecipientMode()) {
            req.addAttribute(MailConstants.A_RECIPIENTS, params.isRecipientMode());
        }
        if (params.getField() != null) {
            req.addAttribute(MailConstants.A_FIELD, params.getField());
        }
        if (params.getInDumpster()) {
            req.addAttribute(MailConstants.A_IN_DUMPSTER, true);
        }

        req.addAttribute(MailConstants.E_QUERY, params.getQuery(), Element.Disposition.CONTENT);

        if (params.getCursor() != null) {
            Cursor cursor = params.getCursor();
            Element cursorEl = req.addElement(MailConstants.E_CURSOR);
            if (cursor.getPreviousId() != null) {
                cursorEl.addAttribute(MailConstants.A_ID, cursor.getPreviousId());
            }
            if (cursor.getPreviousSortValue() != null) {
                cursorEl.addAttribute(MailConstants.A_SORTVAL, cursor.getPreviousSortValue());
            }
        }

        if (params.getTypes().equals(ZSearchParams.TYPE_VOICE_MAIL)
                || params.getTypes().equals(ZSearchParams.TYPE_CALL)) {
            getAllPhoneAccounts();
            setVoiceStorePrincipal(req);
        }
        Element resp = invoke(req);
        if (params.getTypes().equals(ZSearchParams.TYPE_GAL)) {
            try {
                resp.getAttribute(MailConstants.A_SORTBY);

            } catch (Exception e) {
                resp.addAttribute(MailConstants.A_SORTBY, params.getSortBy().name());
            }
            try {
                resp.getAttribute(MailConstants.A_QUERY_OFFSET);

            } catch (Exception e) {
                resp.addAttribute(MailConstants.A_QUERY_OFFSET, params.getOffset());
            }
        }
        return new ZSearchResult(resp, nest,
                params.getTimeZone() != null ? params.getTimeZone() : getPrefs().getTimeZone());
    }

    /**
     * do a search
     * @param params search prams
     * @return search result
     * @throws ServiceException on error
     */
    public synchronized ZSearchResult search(ZSearchParams params) throws ServiceException {
        return internalSearch(null, params, false);
    }

    /**
     * do a search, using potentially cached results for efficient paging forward/backward.
     * Search hits are kept up to date via notifications.
     *
     * @param params search prams. Should not change from call to call.
     * @return search result
     * @throws ServiceException on error
     * @param page page of results to return. page size is determined by limit in params.
     * @param useCache use the cache if possible
     * @param useCursor true to use search cursors, false to use offsets
     */
    public synchronized ZSearchPagerResult search(ZSearchParams params, int page, boolean useCache,
            boolean useCursor) throws ServiceException {
        return mSearchPagerCache.search(this, params, page, useCache, useCursor);
    }

    /**
     *
     * @param type if non-null, clear only cached searches of the specified tape
     */
    public synchronized void clearSearchCache(String type) {
        mSearchPagerCache.clear(type);
    }

    /**
     *  do a search conv
     * @param convId id of conversation to search
     * @param params convId onversation id
     * @return search result
     * @throws ServiceException on error
     */
    public synchronized ZSearchResult searchConversation(String convId, ZSearchParams params)
            throws ServiceException {
        if (convId == null) {
            throw ZClientException.CLIENT_ERROR("conversation id must not be null", null);
        }
        return internalSearch(convId, params, true);
    }

    public synchronized ZSearchPagerResult searchConversation(String convId, ZSearchParams params, int page,
            boolean useCache, boolean useCursor) throws ServiceException {
        if (params.getConvId() == null) {
            params.setConvId(convId);
        }
        return mSearchConvPagerCache.search(this, params, page, useCache, useCursor);
    }

    private void populateFolderCache() throws ServiceException {
        if (mUserRoot != null) {
            return;
        }
        if (mNotifyPreference == null || mNotifyPreference == NotifyPreference.full) {
            noOp();
            if (mUserRoot != null) {
                return;
            }
        }

        GetFolderRequest req = new GetFolderRequest(null, true);
        GetFolderResponse res = invokeJaxb(req);
        Folder root = res.getFolder();
        ZFolder userRoot = (root != null ? new ZFolder(root, null, this) : null);

        ZRefreshEvent event = new ZRefreshEvent(mSize, userRoot, null);
        for (ZEventHandler handler : mHandlers) {
            handler.handleRefresh(event, this);
        }
    }

    private void populateTagCache() throws ServiceException {
        if (mNameToTag != null) {
            return;
        }
        if (mNotifyPreference == null || mNotifyPreference == NotifyPreference.full) {
            noOp();
            if (mNameToTag != null) {
                return;
            }
        }

        List<ZTag> tagList = new ArrayList<ZTag>();
        if (!mNoTagCache) {
            try {
                Element response = invoke(newRequestElement(MailConstants.GET_TAG_REQUEST));
                for (Element t : response.listElements(MailConstants.E_TAG)) {
                    tagList.add(new ZTag(t, this));
                }
            } catch (SoapFaultException sfe) {
                if (!sfe.getCode().equals(ServiceException.PERM_DENIED)) {
                    throw sfe;
                }
            }
        }

        ZRefreshEvent event = new ZRefreshEvent(mSize, null, tagList);
        for (ZEventHandler handler : mHandlers) {
            handler.handleRefresh(event, this);
        }
    }

    /**
     * A request that does nothing and always returns nothing. Used to keep a session alive, and return
     * any pending notifications.
     *
     * @throws ServiceException on error
     */
    public void noOp() throws ServiceException {
        invoke(newRequestElement(MailConstants.NO_OP_REQUEST));
    }

    /**
     * A blocking NoOpRequest which waits up to the specified timeout
     *
     */
    public void noOp(long timeout) throws ServiceException {
        Element e = newRequestElement(MailConstants.NO_OP_REQUEST);
        e.addAttribute(MailConstants.A_WAIT, true);
        e.addAttribute(MailConstants.A_TIMEOUT, timeout);
        invoke(e);
    }

    public enum OwnerBy {
        BY_ID, BY_NAME;

        public static OwnerBy fromString(String s) throws ServiceException {
            try {
                return OwnerBy.valueOf(s);
            } catch (IllegalArgumentException e) {
                throw ZClientException.CLIENT_ERROR(
                        "invalid ownerBy: " + s + ", valid values: " + Arrays.asList(OwnerBy.values()), e);
            }
        }
    }

    public enum SharedItemBy {
        BY_ID, BY_PATH;

        public static SharedItemBy fromString(String s) throws ServiceException {
            try {
                return SharedItemBy.valueOf(s);
            } catch (IllegalArgumentException e) {
                throw ZClientException.CLIENT_ERROR(
                        "invalid sharedItemBy: " + s + ", valid values: " + Arrays.asList(SharedItemBy.values()),
                        e);
            }
        }
    }

    /**
     * create a new mountpoint in the specified parent folder.
     *
     * @param parentId parent folder id
     * @param name name of new folder
     * @param defaultView default view of new folder.
     * @param color
     * @param flags
     * @param ownerBy used to specify whether owner is an id or account name (email address)
     * @param owner either the id or name of the owner
     * @param itemBy used to specify whether sharedItem is an id or path to the shared item
     * @param sharedItem either the id or path of the item
     * @param reminderEnabled whether client should show reminders on appointments/tasks
     *
     * @return newly created folder
     * @throws ServiceException on error
     * @param color initial color
     * @param flags initial flags
     */
    public ZMountpoint createMountpoint(String parentId, String name, ZFolder.View defaultView, ZFolder.Color color,
            String flags, OwnerBy ownerBy, String owner, SharedItemBy itemBy, String sharedItem,
            boolean reminderEnabled) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_MOUNTPOINT_REQUEST);
        Element linkEl = req.addUniqueElement(MailConstants.E_MOUNT);
        linkEl.addAttribute(MailConstants.A_NAME, name);
        linkEl.addAttribute(MailConstants.A_FOLDER, parentId);
        if (defaultView != null) {
            linkEl.addAttribute(MailConstants.A_DEFAULT_VIEW, defaultView.name());
        }
        if (color != null) {
            if (StringUtil.equal(color.getName(), Color.RGBCOLOR)) {
                linkEl.addAttribute(MailConstants.A_RGB, color.getRgbColorValue());
            } else {
                linkEl.addAttribute(MailConstants.A_COLOR, color.getValue());
            }
        }
        if (flags != null) {
            linkEl.addAttribute(MailConstants.A_FLAGS, flags);
        }
        linkEl.addAttribute(ownerBy == OwnerBy.BY_ID ? MailConstants.A_ZIMBRA_ID : MailConstants.A_OWNER_NAME,
                owner);
        linkEl.addAttribute(itemBy == SharedItemBy.BY_ID ? MailConstants.A_REMOTE_ID : MailConstants.A_PATH,
                sharedItem);
        linkEl.addAttribute(MailConstants.A_REMINDER, reminderEnabled);
        Element newMountEl = invoke(req).getElement(MailConstants.E_MOUNT);
        ZMountpoint newMount = getMountpointById(newMountEl.getAttribute(MailConstants.A_ID));
        return newMount != null ? newMount : new ZMountpoint(newMountEl, null, this);
    }

    /**
     * enable/disable displaying reminder for shared appointments/tasks
     * @param mountpointId
     * @param reminderEnabled
     * @throws ServiceException
     */
    public void enableSharedReminder(String mountpointId, boolean reminderEnabled) throws ServiceException {
        Element req = newRequestElement(MailConstants.ENABLE_SHARED_REMINDER_REQUEST);
        Element linkEl = req.addUniqueElement(MailConstants.E_MOUNT);
        linkEl.addAttribute(MailConstants.A_ID, mountpointId);
        linkEl.addAttribute(MailConstants.A_REMINDER, reminderEnabled);
        invoke(req);
    }

    /**
     * Sends an iCalendar REPLY object
     * @param ical iCalendar data
     * @throws ServiceException on error
     */
    public void iCalReply(String ical, String sender) throws ServiceException {
        Element req = newRequestElement(MailConstants.ICAL_REPLY_REQUEST);
        Element icalElem = req.addUniqueElement(MailConstants.E_CAL_ICAL);
        icalElem.setText(ical);
        if (sender != null) {
            icalElem.addAttribute(MailConstants.E_CAL_ATTENDEE, sender);
        }
        invoke(req);
    }

    public static class ZSendMessageResponse {

        private String mId;

        public ZSendMessageResponse(String id) {
            mId = id;
        }

        public String getId() {
            return mId;
        }

        public void setId(String id) {
            mId = id;
        }
    }

    public static class ZOutgoingMessage {

        public static class AttachedMessagePart {
            private String mMessageId;
            private String mPartName;
            private String mContentId;
            private String mAttachmentId;

            public AttachedMessagePart(String messageId, String partName, String contentId) {
                mMessageId = messageId;
                mPartName = partName;
                mContentId = contentId;
            }

            public AttachedMessagePart(String attachmentId, String contentId) {
                mAttachmentId = attachmentId;
                mContentId = contentId;
            }

            public String getMessageId() {
                return mMessageId;
            }

            public void setMessageId(String messageId) {
                mMessageId = messageId;
            }

            public String getContentId() {
                return mContentId;
            }

            public void setContentId(String contentId) {
                mContentId = contentId;
            }

            public String getPartName() {
                return mPartName;
            }

            public void setPartName(String partName) {
                mPartName = partName;
            }

            public String getAttachmentId() {
                return mAttachmentId;
            }

            public void setAttachmentId(String attachmentId) {
                mAttachmentId = attachmentId;
            }
        }

        public static class MessagePart {
            private String mContentType;
            private final String mContent;
            private List<MessagePart> mSubParts;
            private List<AttachedMessagePart> mAttachSubParts;

            /**
             * create a new message part with the given content type and content.
             *
             * @param contentType MIME content type
             * @param content content for the part (null if content-type is multi-part)
             */
            public MessagePart(String contentType, String content) {
                mContent = content;
                mContentType = contentType;
            }

            public MessagePart(String contentType, String content, List<AttachedMessagePart> attachSubParts) {
                mContent = content;
                mContentType = contentType;
                mAttachSubParts = attachSubParts;
            }

            public MessagePart(String contentType, MessagePart... parts) {
                mContent = null;
                mContentType = contentType;
                mSubParts = new ArrayList<MessagePart>();
                for (MessagePart sub : parts) {
                    mSubParts.add(sub);
                }
            }

            public String getContentType() {
                return mContentType;
            }

            public void setContentType(String contentType) {
                mContentType = contentType;
            }

            public String getContent() {
                return mContent;
            }

            public void setContent(String content) {
                mContentType = content;
            }

            public List<MessagePart> getSubParts() {
                return mSubParts;
            }

            public void setSubParts(List<MessagePart> subParts) {
                mSubParts = subParts;
            }

            public List<AttachedMessagePart> getAttachSubParts() {
                return mAttachSubParts;
            }

            public void setAttachSubParts(List<AttachedMessagePart> attachSubParts) {
                mAttachSubParts = attachSubParts;
            }

            public Element toElement(Element parent) {
                Element mpEl = parent.addElement(MailConstants.E_MIMEPART);
                mpEl.addAttribute(MailConstants.A_CONTENT_TYPE, mContentType);
                if (mContent != null) {
                    mpEl.addElement(MailConstants.E_CONTENT).setText(mContent);
                }
                if (mSubParts != null) {
                    for (MessagePart subPart : mSubParts) {
                        subPart.toElement(mpEl);
                    }
                }

                if (mAttachSubParts != null) {

                    for (AttachedMessagePart subPart : mAttachSubParts) {
                        Element e = parent.addElement(MailConstants.E_MIMEPART);
                        e.addAttribute(MailConstants.A_CONTENT_ID, subPart.getContentId());
                        Element attach = e.addElement(MailConstants.E_ATTACH);
                        if (subPart.getMessageId() != null) {
                            Element el = attach.addElement(MailConstants.E_MIMEPART);
                            el.addAttribute(MailConstants.A_MESSAGE_ID, subPart.getMessageId());
                            el.addAttribute(MailConstants.A_PART, subPart.getPartName());
                        } else {
                            attach.addAttribute(MailConstants.A_ATTACHMENT_ID, subPart.getAttachmentId());
                        }
                    }
                }
                return mpEl;
            }
        }

        private List<ZEmailAddress> mAddresses;
        private String mSubject;
        private String mPriority;
        private String mInReplyTo;
        private MessagePart mMessagePart;
        private String mAttachmentUploadId;
        private List<AttachedMessagePart> mMessagePartsToAttach;
        private List<String> mContactIdsToAttach;
        private List<String> mMessageIdsToAttach;
        private List<String> mDocIdsToAttach;
        private String mOriginalMessageId;
        private String mMessageId;
        private String mDraftMessageId;
        private String mReplyType;
        private String mIdentityId;

        public List<ZEmailAddress> getAddresses() {
            return mAddresses;
        }

        public void setAddresses(List<ZEmailAddress> addresses) {
            mAddresses = addresses;
        }

        public String getAttachmentUploadId() {
            return mAttachmentUploadId;
        }

        public void setAttachmentUploadId(String attachmentUploadId) {
            mAttachmentUploadId = attachmentUploadId;
        }

        public List<String> getContactIdsToAttach() {
            return mContactIdsToAttach;
        }

        public void setContactIdsToAttach(List<String> contactIdsToAttach) {
            mContactIdsToAttach = contactIdsToAttach;
        }

        public MessagePart getMessagePart() {
            return mMessagePart;
        }

        public void setMessagePart(MessagePart messagePart) {
            mMessagePart = messagePart;
        }

        public List<AttachedMessagePart> getMessagePartsToAttach() {
            return mMessagePartsToAttach;
        }

        public void setMessagePartsToAttach(List<AttachedMessagePart> messagePartsToAttach) {
            mMessagePartsToAttach = messagePartsToAttach;
        }

        public String getOriginalMessageId() {
            return mOriginalMessageId;
        }

        public void setOriginalMessageId(String originalMessageId) {
            mOriginalMessageId = originalMessageId;
        }

        public String getMessageId() {
            return mMessageId;
        }

        public void setMessageId(String messageId) {
            mMessageId = messageId;
        }

        public String getDraftMessageId() {
            return mDraftMessageId;
        }

        public void setDraftMessageId(String draftMessageId) {
            mDraftMessageId = draftMessageId;
        }

        public String getInReplyTo() {
            return mInReplyTo;
        }

        public void setInReplyTo(String inReplyTo) {
            mInReplyTo = inReplyTo;
        }

        public String getReplyType() {
            return mReplyType;
        }

        public void setReplyType(String replyType) {
            mReplyType = replyType;
        }

        public String getSubject() {
            return mSubject;
        }

        public void setSubject(String subject) {
            mSubject = subject;
        }

        public String getPriority() {
            return mPriority;
        }

        public void setPriority(String priority) {
            mPriority = priority;
        }

        public List<String> getMessageIdsToAttach() {
            return mMessageIdsToAttach;
        }

        public void setMessageIdsToAttach(List<String> messageIdsToAttach) {
            mMessageIdsToAttach = messageIdsToAttach;
        }

        public String getIdentityId() {
            return mIdentityId;
        }

        public void setIdentityId(String id) {
            mIdentityId = id;
        }

        public List<String> getDocIdsToAttach() {
            return mDocIdsToAttach;
        }

        public void setDocIdsToAttach(List<String> docIdsToAttach) {
            mDocIdsToAttach = docIdsToAttach;
        }

        public List<AttachedMessagePart> getInlineMessagePartsToAttach() {
            List<AttachedMessagePart> attachments = new ArrayList<AttachedMessagePart>();
            if (!ListUtil.isEmpty(mMessagePartsToAttach)) {
                for (AttachedMessagePart part : mMessagePartsToAttach) {
                    if (part.getContentId() != null && !part.getContentId().equals("")) {
                        attachments.add(part);
                    }
                }
            }
            return attachments;
        }
    }

    public Element getMessageElement(Element req, ZOutgoingMessage message, ZMountpoint mountpoint) {
        Element m = req.addElement(MailConstants.E_MSG);

        String id = message.getOriginalMessageId();
        if (mountpoint != null) {
            // Use normalized id for a shared folder
            int idx = id.indexOf(":");
            if (idx != -1) {
                id = id.substring(idx + 1);
            }
        }
        if (id != null) {
            m.addAttribute(MailConstants.A_ORIG_ID, id);
        }

        String msgId = message.getMessageId();
        if (msgId != null) {
            m.addAttribute(MailConstants.A_ID, msgId);
        }

        String draftId = message.getDraftMessageId();
        if (draftId != null) {
            m.addAttribute(MailConstants.A_DRAFT_ID, draftId);
        }

        if (message.getReplyType() != null) {
            m.addAttribute(MailConstants.A_REPLY_TYPE, message.getReplyType());
        }

        if (message.getAddresses() != null) {
            for (ZEmailAddress addr : message.getAddresses()) {
                if (mountpoint != null && addr.getType().equals(ZEmailAddress.EMAIL_TYPE_FROM)) {
                    //  For on behalf of messages, replace the from: and add a sender:
                    Element e = m.addElement(MailConstants.E_EMAIL);
                    e.addAttribute(MailConstants.A_TYPE, ZEmailAddress.EMAIL_TYPE_SENDER);
                    e.addAttribute(MailConstants.A_ADDRESS, addr.getAddress());

                    e = m.addElement(MailConstants.E_EMAIL);
                    e.addAttribute(MailConstants.A_TYPE, ZEmailAddress.EMAIL_TYPE_FROM);
                    e.addAttribute(MailConstants.A_ADDRESS, mountpoint.getOwnerDisplayName());
                } else {
                    Element e = m.addElement(MailConstants.E_EMAIL);
                    e.addAttribute(MailConstants.A_TYPE, addr.getType());
                    e.addAttribute(MailConstants.A_ADDRESS, addr.getAddress());
                    e.addAttribute(MailConstants.A_PERSONAL, addr.getPersonal());
                }
            }
        }

        if (message.getSubject() != null) {
            m.addElement(MailConstants.E_SUBJECT).setText(message.getSubject());
        }

        if (message.getPriority() != null && message.getPriority().length() != 0) {
            m.addAttribute(MailConstants.A_FLAGS, message.getPriority());
        }

        if (message.getInReplyTo() != null) {
            m.addElement(MailConstants.E_IN_REPLY_TO).setText(message.getInReplyTo());
        }

        if (message.getMessagePart() != null) {
            message.getMessagePart().toElement(m);
        }

        Element attach = null;

        if (message.getAttachmentUploadId() != null) {
            attach = m.addElement(MailConstants.E_ATTACH);
            attach.addAttribute(MailConstants.A_ATTACHMENT_ID, message.getAttachmentUploadId());
        }

        if (message.getMessageIdsToAttach() != null) {
            if (attach == null) {
                attach = m.addElement(MailConstants.E_ATTACH);
            }
            for (String mid : message.getMessageIdsToAttach()) {
                attach.addElement(MailConstants.E_MSG).addAttribute(MailConstants.A_ID, mid);
            }
        }
        if (message.getDocIdsToAttach() != null) {
            if (attach == null) {
                attach = m.addElement(MailConstants.E_ATTACH);
            }
            for (String did : message.getDocIdsToAttach()) {
                attach.addElement(MailConstants.E_DOC).addAttribute(MailConstants.A_ID, did);
            }
        }
        if (message.getContactIdsToAttach() != null) {
            if (attach == null)
                attach = m.addElement(MailConstants.E_ATTACH);
            for (String cid : message.getContactIdsToAttach()) {
                attach.addElement(MailConstants.E_CONTACT).addAttribute(MailConstants.A_ID, cid);
            }
        }
        if (message.getMessagePartsToAttach() != null) {
            if (attach == null) {
                attach = m.addElement(MailConstants.E_ATTACH);
            }
            for (AttachedMessagePart part : message.getMessagePartsToAttach()) {
                if (part.getContentId() == null || part.getContentId().equals("")) {
                    attach.addElement(MailConstants.E_MIMEPART)
                            .addAttribute(MailConstants.A_MESSAGE_ID, part.getMessageId())
                            .addAttribute(MailConstants.A_PART, part.getPartName());
                }
            }
        }
        return m;
    }

    private ZMountpoint getMountpoint(ZOutgoingMessage message) throws ServiceException {
        ZMountpoint mountpoint = null;
        String oringinalId = message.getOriginalMessageId();
        if (oringinalId != null) {
            ZGetMessageParams params = new ZGetMessageParams();
            params.setId(oringinalId);
            params.setPart("");
            ZMessage original = getMessage(params);
            ZFolder folder = getFolderById(original.getFolderId());
            if (folder instanceof ZMountpoint) {
                mountpoint = (ZMountpoint) folder;
            }
        }
        return mountpoint;
    }

    public ZSendMessageResponse sendMessage(ZOutgoingMessage message, String sendUid,
            boolean needCalendarSentByFixup) throws ServiceException {
        Element req = newRequestElement(MailConstants.SEND_MSG_REQUEST);

        if (sendUid != null && sendUid.length() > 0) {
            req.addAttribute(MailConstants.A_SEND_UID, sendUid);
        }

        if (needCalendarSentByFixup) {
            req.addAttribute(MailConstants.A_NEED_CALENDAR_SENTBY_FIXUP, needCalendarSentByFixup);
        }

        ZMountpoint mountpoint = getMountpoint(message);

        //noinspection UnusedDeclaration
        getMessageElement(req, message, mountpoint);

        String requestedAccountId = mountpoint == null ? null : mountpoint.getOwnerId();
        Element resp = invoke(req, requestedAccountId);
        Element msg = resp.getOptionalElement(MailConstants.E_MSG);
        String id = msg == null ? null : msg.getAttribute(MailConstants.A_ID, null);
        return new ZSendMessageResponse(id);
    }

    /**
     * Saves a message draft.
     *
     * @param message the message
     * @param existingDraftId id of existing draft or <tt>null</tt>
     * @param folderId folder to save to or <tt>null</tt> to save to the <tt>Drafts</tt> folder
     *
     * @return the message
     */
    public synchronized ZMessage saveDraft(ZOutgoingMessage message, String existingDraftId, String folderId)
            throws ServiceException {
        return saveDraft(message, existingDraftId, folderId, 0);
    }

    /**
     * Saves a message draft.
     *
     * @param message the message
     * @param existingDraftId id of existing draft or <tt>null</tt>
     * @param folderId folder to save to or <tt>null</tt> to save to the <tt>Drafts</tt> folder
     * @param autoSendTime time in UTC millis at which the draft should be auto-sent by the server.
     *                     zero value implies a normal draft, i.e. no auto-send intended.
     *
     * @return the message
     */
    public synchronized ZMessage saveDraft(ZOutgoingMessage message, String existingDraftId, String folderId,
            long autoSendTime) throws ServiceException {
        Element req = newRequestElement(MailConstants.SAVE_DRAFT_REQUEST);

        ZMountpoint mountpoint = getMountpoint(message);
        Element m = getMessageElement(req, message, mountpoint);

        if (existingDraftId != null && existingDraftId.length() > 0) {
            mMessageCache.remove(existingDraftId);
            m.addAttribute(MailConstants.A_ID, existingDraftId);
        }

        if (folderId != null) {
            m.addAttribute(MailConstants.A_FOLDER, folderId);
        }

        if (autoSendTime != 0) {
            m.addAttribute(MailConstants.A_AUTO_SEND_TIME, autoSendTime);
        }

        if (message.getIdentityId() != null) {
            m.addAttribute(MailConstants.A_IDENTITY_ID, message.getIdentityId());
        }

        String requestedAccountId = mountpoint == null ? null : mGetInfoResult.getId();
        return new ZMessage(invoke(req, requestedAccountId).getElement(MailConstants.E_MSG), this);
    }

    public synchronized CheckSpellingResponse checkSpelling(String text) throws ServiceException {
        return checkSpelling(text, null, null);
    }

    public synchronized CheckSpellingResponse checkSpelling(String text, String dictionary)
            throws ServiceException {
        return checkSpelling(text, dictionary, null);
    }

    public synchronized CheckSpellingResponse checkSpelling(String text, String dictionary, List<String> ignore)
            throws ServiceException {
        String ignoreList = (ignore == null ? null : StringUtil.join(",", ignore));
        CheckSpellingRequest req = new CheckSpellingRequest(dictionary, ignoreList, text);
        return invokeJaxb(req);
    }

    public void createIdentity(ZIdentity identity) throws ServiceException {
        Element req = newRequestElement(AccountConstants.CREATE_IDENTITY_REQUEST);
        identity.toElement(req);
        invoke(req);
    }

    public List<ZIdentity> getIdentities() throws ServiceException {
        GetIdentitiesResponse res = invokeJaxb(new GetIdentitiesRequest());
        return ListUtil.newArrayList(res.getIdentities(), SoapConverter.FROM_SOAP_IDENTITY);
    }

    public void deleteIdentity(String name) throws ServiceException {
        deleteIdentity(Key.IdentityBy.name, name);
    }

    public void deleteIdentity(Key.IdentityBy by, String key) throws ServiceException {
        Element req = newRequestElement(AccountConstants.DELETE_IDENTITY_REQUEST);
        if (by == Key.IdentityBy.name) {
            req.addUniqueElement(AccountConstants.E_IDENTITY).addAttribute(AccountConstants.A_NAME, key);
        } else if (by == Key.IdentityBy.id) {
            req.addUniqueElement(AccountConstants.E_IDENTITY).addAttribute(AccountConstants.A_ID, key);
        }
        invoke(req);
    }

    public void modifyIdentity(ZIdentity identity) throws ServiceException {
        Element req = newRequestElement(AccountConstants.MODIFY_IDENTITY_REQUEST);
        identity.toElement(req);
        invoke(req);
    }

    /**
     * Creates a data source.
     *
     * @return the new data source id
     */
    public String createDataSource(ZDataSource source) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_DATA_SOURCE_REQUEST);
        source.toElement(req);
        return invoke(req).listElements().get(0).getAttribute(MailConstants.A_ID);
    }

    /**
     * Tests a data source.
     *
     * @return <tt>null</tt> on success, or the error string on failure
     */
    public String testDataSource(ZDataSource source) throws ServiceException {
        Element req = newRequestElement(MailConstants.TEST_DATA_SOURCE_REQUEST);
        source.toElement(req);
        Element resp = invoke(req);
        List<Element> children = resp.listElements();
        if (children.size() == 0) {
            return MailConstants.TEST_DATA_SOURCE_RESPONSE + " has no child elements";
        }
        Element dsEl = children.get(0);
        boolean success = dsEl.getAttributeBool(MailConstants.A_DS_SUCCESS, false);
        if (!success) {
            return resp.getAttribute(MailConstants.A_DS_ERROR, "error");
        } else {
            return null;
        }
    }

    public List<ZDataSource> getAllDataSources() throws ServiceException {
        GetDataSourcesResponse res = invokeJaxb(new GetDataSourcesRequest());
        List<ZDataSource> result = new ArrayList<ZDataSource>();
        for (DataSource ds : res.getDataSources()) {
            if (ds instanceof Pop3DataSource) {
                result.add(new ZPop3DataSource((Pop3DataSource) ds));
            } else if (ds instanceof ImapDataSource) {
                result.add(new ZImapDataSource((ImapDataSource) ds));
            } else if (ds instanceof CalDataSource) {
                result.add(new ZCalDataSource((CalDataSource) ds));
            } else if (ds instanceof RssDataSource) {
                result.add(new ZRssDataSource((RssDataSource) ds));
            }
        }
        return result;
    }

    /**
     * Gets a data source by id.
     * @return the data source, or <tt>null</tt> if no data source with the
     * given id exists
     */
    public ZDataSource getDataSourceById(String id) throws ServiceException {
        for (ZDataSource ds : getAllDataSources()) {
            if (ds.getId().equals(id)) {
                return ds;
            }
        }
        return null;
    }

    public void modifyDataSource(ZDataSource source) throws ServiceException {
        Element req = newRequestElement(MailConstants.MODIFY_DATA_SOURCE_REQUEST);
        source.toElement(req);
        invoke(req);
    }

    public void deleteDataSource(ZDataSource source) throws ServiceException {
        Element req = newRequestElement(MailConstants.DELETE_DATA_SOURCE_REQUEST);
        source.toIdElement(req);
        invoke(req);
    }

    public ZFilterRules getIncomingFilterRules() throws ServiceException {
        return getIncomingFilterRules(false);
    }

    public ZFilterRules getOutgoingFilterRules() throws ServiceException {
        return getOutgoingFilterRules(false);
    }

    public synchronized ZFilterRules getIncomingFilterRules(boolean refresh) throws ServiceException {
        if (incomingRules == null || refresh) {
            GetFilterRulesResponse resp = invokeJaxb(new GetFilterRulesRequest());
            incomingRules = new ZFilterRules(resp.getFilterRules());
        }
        return new ZFilterRules(incomingRules);
    }

    public synchronized ZFilterRules getOutgoingFilterRules(boolean refresh) throws ServiceException {
        if (outgoingRules == null || refresh) {
            GetOutgoingFilterRulesResponse resp = invokeJaxb(new GetOutgoingFilterRulesRequest());
            outgoingRules = new ZFilterRules(resp.getFilterRules());
        }
        return new ZFilterRules(outgoingRules);
    }

    public synchronized void saveIncomingFilterRules(ZFilterRules rules) throws ServiceException {
        ModifyFilterRulesRequest req = new ModifyFilterRulesRequest();
        req.addFilterRules(rules.toJAXB());
        invokeJaxb(req);
        incomingRules = new ZFilterRules(rules);
    }

    public synchronized void saveOutgoingFilterRules(ZFilterRules rules) throws ServiceException {
        ModifyOutgoingFilterRulesRequest req = new ModifyOutgoingFilterRulesRequest();
        req.addFilterRule(rules.toJAXB());
        invokeJaxb(req);
        outgoingRules = new ZFilterRules(rules);
    }

    public void deleteDataSource(Key.DataSourceBy by, String key) throws ServiceException {
        Element req = newRequestElement(MailConstants.DELETE_DATA_SOURCE_REQUEST);
        if (by == Key.DataSourceBy.name) {
            req.addUniqueElement(MailConstants.E_DS).addAttribute(MailConstants.A_NAME, key);
        } else if (by == Key.DataSourceBy.id) {
            req.addUniqueElement(MailConstants.E_DS).addAttribute(MailConstants.A_ID, key);
        } else {
            throw ServiceException.INVALID_REQUEST("must specify data source by id or name", null);
        }
        invoke(req);
    }

    public void importData(List<ZDataSource> sources) throws ServiceException {
        Element req = newRequestElement(MailConstants.IMPORT_DATA_REQUEST);
        for (ZDataSource src : sources) {
            src.toIdElement(req);
        }
        invoke(req);
    }

    public static class ZImportStatus {
        private final String mType;
        private final boolean mIsRunning;
        private final boolean mSuccess;
        private final String mError;
        private final String mId;

        ZImportStatus(Element e) throws ServiceException {
            mType = e.getName();
            mId = e.getAttribute(MailConstants.A_ID);
            mIsRunning = e.getAttributeBool(MailConstants.A_DS_IS_RUNNING, false);
            mSuccess = e.getAttributeBool(MailConstants.A_DS_SUCCESS, true);
            mError = e.getAttribute(MailConstants.A_DS_ERROR, null);
        }

        public String getType() {
            return mType;
        }

        public String getId() {
            return mId;
        }

        public boolean isRunning() {
            return mIsRunning;
        }

        public boolean getSuccess() {
            return mSuccess;
        }

        public String getError() {
            return mError;
        }
    }

    public List<ZImportStatus> getImportStatus() throws ServiceException {
        Element req = newRequestElement(MailConstants.GET_IMPORT_STATUS_REQUEST);
        Element response = invoke(req);
        List<ZImportStatus> result = new ArrayList<ZImportStatus>();
        for (Element status : response.listElements()) {
            result.add(new ZImportStatus(status));
        }
        return result;
    }

    public String createDocument(String folderId, String name, String attachmentId) throws ServiceException {
        return createDocument(folderId, name, attachmentId, false);
    }

    public String createDocument(String folderId, String name, String attachmentId, boolean isNote)
            throws ServiceException {
        Element req = newRequestElement(MailConstants.SAVE_DOCUMENT_REQUEST);
        Element doc = req.addUniqueElement(MailConstants.E_DOC);
        doc.addAttribute(MailConstants.A_NAME, name);
        doc.addAttribute(MailConstants.A_FOLDER, folderId);
        if (isNote) {
            doc.addAttribute(MailConstants.A_FLAGS, ZItem.Flag.note.toString());
        }
        Element upload = doc.addElement(MailConstants.E_UPLOAD);
        upload.addAttribute(MailConstants.A_ID, attachmentId);
        return invoke(req).getElement(MailConstants.E_DOC).getAttribute(MailConstants.A_ID);
    }

    public ZDocument getDocument(String id) throws ServiceException {
        Element req = newRequestElement(MailConstants.GET_ITEM_REQUEST);
        Element item = req.addUniqueElement(MailConstants.E_ITEM);
        item.addAttribute(MailConstants.A_ID, id);
        Element e = invoke(req).getElement(MailConstants.E_DOC);
        return new ZDocument(e);
    }

    /**
     * modify prefs. The key in the map is the pref name, and the value should be a String[],
     * a Collection of String objects, or a single String/Object.toString.
     * @param prefs prefs to modify
     * @throws ServiceException on error
     */
    public void modifyPrefs(Map<String, ? extends Object> prefs) throws ServiceException {
        Element req = newRequestElement(AccountConstants.MODIFY_PREFS_REQUEST);
        for (Map.Entry<String, ? extends Object> entry : prefs.entrySet()) {
            Object vo = entry.getValue();
            if (vo instanceof String[]) {
                String[] values = (String[]) vo;
                for (String v : values) {
                    req.addKeyValuePair(entry.getKey(), v, AccountConstants.E_PREF, AccountConstants.A_NAME);
                }
            } else if (vo instanceof Collection) {
                @SuppressWarnings("rawtypes")
                Collection values = (Collection) vo;
                for (Object v : values) {
                    req.addKeyValuePair(entry.getKey(), v.toString(), AccountConstants.E_PREF,
                            AccountConstants.A_NAME);
                }
            } else {
                req.addKeyValuePair(entry.getKey(), vo.toString(), AccountConstants.E_PREF,
                        AccountConstants.A_NAME);
            }
        }
        invoke(req);
    }

    public List<String> getAvailableSkins() throws ServiceException {
        Element req = newRequestElement(AccountConstants.GET_AVAILABLE_SKINS_REQUEST);
        Element resp = invoke(req);
        List<String> result = new ArrayList<String>();
        for (Element skin : resp.listElements(AccountConstants.E_SKIN)) {
            String name = skin.getAttribute(AccountConstants.A_NAME, null);
            if (name != null) {
                result.add(name);
            }
        }
        Collections.sort(result);
        return result;
    }

    public List<String> getAvailableLocales() throws ServiceException {
        Element req = newRequestElement(AccountConstants.GET_AVAILABLE_LOCALES_REQUEST);
        Element resp = invoke(req);
        List<String> result = new ArrayList<String>();
        for (Element locale : resp.listElements(AccountConstants.E_LOCALE)) {
            String id = locale.getAttribute(AccountConstants.A_ID, null);
            if (id != null) {
                result.add(id);
            }
        }
        Collections.sort(result);
        return result;
    }

    public enum GalEntryType {
        account, resource, all;

        public static GalEntryType fromString(String s) throws ServiceException {
            try {
                return GalEntryType.valueOf(s);
            } catch (IllegalArgumentException e) {
                throw ZClientException.CLIENT_ERROR(
                        "invalid GalType: " + s + ", valid values: " + Arrays.asList(GalEntryType.values()), e);
            }
        }
    }

    public static class ZSearchGalResult {
        private final boolean mMore;
        private final List<ZContact> mContacts;
        private final String mQuery;
        private final GalEntryType mType;

        public ZSearchGalResult(List<ZContact> contacts, boolean more, String query, GalEntryType type) {
            mMore = more;
            mContacts = contacts;
            mQuery = query;
            mType = type;
        }

        public boolean getHasMore() {
            return mMore;
        }

        public List<ZContact> getContacts() {
            return mContacts;
        }

        public String getQuery() {
            return mQuery;
        }

        public GalEntryType getGalEntryType() {
            return mType;
        }
    }

    public void applyConditions(ArrayList<Object> conditions, Element parentCondition) {
        for (Object condition : conditions) {
            if (condition instanceof ArrayList) {
                // Conditions
                Element element = parentCondition.addElement(AccountConstants.E_ENTRY_SEARCH_FILTER_MULTICOND);
                element.addAttribute(AccountConstants.A_ENTRY_SEARCH_FILTER_OR, true);
                applyConditions((ArrayList<Object>) condition, element);
            }
            if (condition instanceof String[]) {
                String conditionAttr[] = (String[]) condition;
                Element conditionElem = parentCondition
                        .addElement(AccountConstants.E_ENTRY_SEARCH_FILTER_SINGLECOND);
                conditionElem.addAttribute(AccountConstants.A_ENTRY_SEARCH_FILTER_ATTR, conditionAttr[0]);
                conditionElem.addAttribute(AccountConstants.A_ENTRY_SEARCH_FILTER_OP, conditionAttr[1]);
                conditionElem.addAttribute(AccountConstants.A_ENTRY_SEARCH_FILTER_VALUE, conditionAttr[2]);
            }
        }
    }

    public ZSearchGalResult searchGal(String query, ArrayList<Object> conditions, GalEntryType type)
            throws ServiceException {
        Element req = newRequestElement(AccountConstants.SEARCH_GAL_REQUEST);
        if (type != null) {
            req.addAttribute(AccountConstants.A_TYPE, type.name());
        }
        req.addElement(AccountConstants.E_NAME).setText(query);

        if (conditions.size() > 0) {
            Element searchFilterElem = req.addElement(AccountConstants.E_ENTRY_SEARCH_FILTER);
            Element condsElement = searchFilterElem.addElement(AccountConstants.E_ENTRY_SEARCH_FILTER_MULTICOND);
            condsElement.addAttribute(AccountConstants.A_ENTRY_SEARCH_FILTER_OR, false);

            applyConditions(conditions, condsElement);
        }

        Element resp = invoke(req);
        List<ZContact> contacts = new ArrayList<ZContact>();
        for (Element contact : resp.listElements(MailConstants.E_CONTACT)) {
            contacts.add(new ZContact(contact, true, this));
        }
        return new ZSearchGalResult(contacts, resp.getAttributeBool(AccountConstants.A_MORE, false), query, type);
    }

    public ZSearchGalResult autoCompleteGal(String query, GalEntryType type, int limit) throws ServiceException {
        Element req = newRequestElement(AccountConstants.AUTO_COMPLETE_GAL_REQUEST);
        if (type != null) {
            req.addAttribute(AccountConstants.A_TYPE, type.name());
        }
        req.addAttribute(AccountConstants.A_LIMIT, limit);
        req.addElement(AccountConstants.E_NAME).setText(query);
        Element resp = invoke(req);
        List<ZContact> contacts = new ArrayList<ZContact>();
        for (Element contact : resp.listElements(MailConstants.E_CONTACT)) {
            contacts.add(new ZContact(contact, true, this));
        }
        return new ZSearchGalResult(contacts, resp.getAttributeBool(AccountConstants.A_MORE, false), query, type);
    }

    public static class ZApptSummaryResult {
        private final String mFolderId;
        private final List<ZAppointmentHit> mAppointments;
        private final long mStart;
        private final long mEnd;
        private final TimeZone mTimeZone;
        private final String mQuery;

        ZApptSummaryResult(long start, long end, String folderId, TimeZone timeZone,
                List<ZAppointmentHit> appointments, String query) {
            mFolderId = folderId;
            mAppointments = appointments;
            mStart = start;
            mEnd = end;
            mTimeZone = timeZone;
            mQuery = query;
        }

        public String getFolderId() {
            return mFolderId;
        }

        public TimeZone getTimeZone() {
            return mTimeZone;
        }

        public long getStart() {
            return mStart;
        }

        public long getEnd() {
            return mEnd;
        }

        public List<ZAppointmentHit> getAppointments() {
            return mAppointments;
        }

        public String getQuery() {
            return mQuery;
        }
    }

    /**
     * clear all entries in the appointment summary cache. This is normally handled automatically
     * via notifications, except in the case of shared calendars.
     */
    public synchronized void clearApptSummaryCache() {
        mApptSummaryCache.clear();
    }

    public static class ZGetMiniCalResult {
        private final Set<String> mDates;
        private final List<ZMiniCalError> mErrors;

        public ZGetMiniCalResult(Set<String> dates, List<ZMiniCalError> errors) {
            mDates = dates;
            mErrors = errors;
        }

        public Set<String> getDates() {
            return mDates;
        }

        public List<ZMiniCalError> getErrors() {
            return mErrors;
        }
    }

    public static class ZMiniCalError {
        private final String mFolderId;
        private final String mErrCode;
        private final String mErrMsg;

        public ZMiniCalError(String folderId, String errcode, String errmsg) {
            mFolderId = folderId;
            mErrCode = errcode;
            mErrMsg = errmsg;
        }

        public String getFolderId() {
            return mFolderId;
        }

        public String getErrCode() {
            return mErrCode;
        }

        public String getErrMsg() {
            return mErrMsg;
        }
    }

    public synchronized ZGetMiniCalResult getMiniCal(long startMsec, long endMsec, String folderIds[])
            throws ServiceException {
        Set<String> dates = mApptSummaryCache.getMiniCal(startMsec, endMsec, folderIds);
        List<ZMiniCalError> errors = null;

        if (dates == null) {
            Element req = newRequestElement(MailConstants.GET_MINI_CAL_REQUEST);
            req.addAttribute(MailConstants.A_CAL_START_TIME, startMsec);
            req.addAttribute(MailConstants.A_CAL_END_TIME, endMsec);
            for (String folderId : folderIds) {
                Element folderElem = req.addElement(MailConstants.E_FOLDER);
                folderElem.addAttribute(MailConstants.A_ID, folderId);
            }
            Element resp = invoke(req);
            dates = new HashSet<String>();
            for (Element date : resp.listElements(MailConstants.E_CAL_MINICAL_DATE)) {
                dates.add(date.getTextTrim());
            }
            mApptSummaryCache.putMiniCal(dates, startMsec, endMsec, folderIds);
            for (Element error : resp.listElements(MailConstants.E_ERROR)) {
                String fid = error.getAttribute(MailConstants.A_ID);
                String code = error.getAttribute(MailConstants.A_CAL_CODE);
                String msg = error.getTextTrim();
                if (errors == null) {
                    errors = new ArrayList<ZMiniCalError>();
                }
                errors.add(new ZMiniCalError(fid, code, msg));
            }
        }
        return new ZGetMiniCalResult(dates, errors);
    }

    /**
     * Validates the given set of folder ids.  If a folder id corresponds to a mountpoint
     * that is not accessible, that id is omitted from the returned list.
     */
    public synchronized String getValidFolderIds(String ids) throws ServiceException {
        if (StringUtil.isNullOrEmpty(ids)) {
            return "";
        }

        // 1. Separate Local FolderIds and Remote FolderIds
        // sbResult is a list of valid folderIds
        // sbRemote is a list of mountpoints
        Set<String> mountpointIds = new HashSet<String>();
        Set<String> validIds = new HashSet<String>();

        for (String id : ids.split(",")) {
            ZFolder f = getFolderById(id);
            if (f instanceof ZMountpoint) {
                mountpointIds.add(id);
            } else {
                validIds.add(id);
            }
        }

        //2. Send a batch request GetFolderRequest with sbRemote as input
        try {
            Element batch = newRequestElement(ZimbraNamespace.E_BATCH_REQUEST);
            //Element resp;
            for (String id : mountpointIds) {
                Element folderrequest = batch.addElement(MailConstants.GET_FOLDER_REQUEST);
                Element e = folderrequest.addElement(MailConstants.E_FOLDER);
                e.addAttribute(MailConstants.A_FOLDER, id);
            }

            Element resp = mTransport.invoke(batch);
            //3. Parse the response and add valid folderIds to sbResult.
            for (Element e : resp.listElements()) {
                if (e.getName().equals(MailConstants.GET_FOLDER_RESPONSE.getName())) {
                    boolean isBrokenMountpoint = e.getElement(MailConstants.E_MOUNT)
                            .getAttributeBool(MailConstants.A_BROKEN, false);
                    if (!isBrokenMountpoint) {
                        String id = e.getElement(MailConstants.E_MOUNT).getAttribute(MailConstants.A_ID);
                        validIds.add(id);
                    }
                }
            }

            return StringUtil.join(",", validIds);
        } catch (IOException e) {
            throw ZClientException.IO_ERROR("invoke " + e.getMessage(), e);
        }
    }

    /**
     * @param query optional seach query to limit appts returend
     * @param startMsec starting time of range, in msecs
     * @param endMsec ending time of range, in msecs
     * @param folderIds list of folder ids
     * @param timeZone TimeZone used to correct allday appts
     * @param types ZSearchParams.TYPE_APPOINTMENT and/or ZSearchParams.TYPE_TASK. If null, TYPE_APPOINTMENT is used.
     * @return list of appts within the specified range
     * @throws ServiceException on error
     */
    public synchronized List<ZApptSummaryResult> getApptSummaries(String query, long startMsec, long endMsec,
            String folderIds[], TimeZone timeZone, String types) throws ServiceException {

        if (types == null) {
            types = ZSearchParams.TYPE_APPOINTMENT;
        }
        if (query == null) {
            query = "";
        }
        if (folderIds == null || folderIds.length == 0) {
            folderIds = new String[] { ZFolder.ID_CALENDAR };
        }

        List<ZApptSummaryResult> summaries = new ArrayList<ZApptSummaryResult>();
        List<String> idsToFetch = new ArrayList<String>(folderIds.length);

        for (String folderId : folderIds) {
            if (folderId == null) {
                folderId = ZFolder.ID_CALENDAR;
            }
            ZApptSummaryResult cached = mApptSummaryCache.get(startMsec, endMsec, folderId, timeZone, query);
            if (cached == null) {
                idsToFetch.add(folderId);
            } else {
                summaries.add(cached);
            }
        }

        Map<String, ZApptSummaryResult> folder2List = new HashMap<String, ZApptSummaryResult>();
        Map<String, String> folderIdMapper = new HashMap<String, String>();

        String targetId = mTransport.getTargetAcctId();

        if (!idsToFetch.isEmpty()) {
            StringBuilder searchQuery = new StringBuilder();
            searchQuery.append("(");
            for (String folderId : idsToFetch) {
                if (searchQuery.length() > 1) {
                    searchQuery.append(" or ");
                }
                searchQuery.append("inid:").append("\"" + folderId + "\"");
                //folder2List.
                List<ZAppointmentHit> appts = new ArrayList<ZAppointmentHit>();
                ZApptSummaryResult result = new ZApptSummaryResult(startMsec, endMsec, folderId, timeZone, appts,
                        query);
                summaries.add(result);
                folder2List.put(folderId, result);
                ZFolder folder = targetId != null ? null : getFolderById(folderId);
                if (folder != null && folder instanceof ZMountpoint) {
                    folderIdMapper.put(((ZMountpoint) folder).getCanonicalRemoteId(), folderId);
                } else if (targetId != null) {
                    folderIdMapper.put(mTransport.getTargetAcctId() + ":" + folderId, folderId);
                    folderIdMapper.put(folderId, folderId);
                } else {
                    folderIdMapper.put(folderId, folderId);
                }
            }
            searchQuery.append(")");

            if (query.length() > 0) {
                searchQuery.append("AND (").append(query).append(")");
            }

            ZSearchParams params = new ZSearchParams(searchQuery.toString());
            params.setCalExpandInstStart(startMsec);
            params.setCalExpandInstEnd(endMsec);
            params.setTypes(types);
            params.setLimit(2000);
            params.setSortBy(SearchSortBy.none);
            params.setTimeZone(timeZone);

            int offset = 0;
            int n = 0;
            // really while(true), but add in a safety net?
            while (n++ < 100) {
                params.setOffset(offset);
                ZSearchResult result = search(params);
                for (ZSearchHit hit : result.getHits()) {
                    offset++;
                    if (hit instanceof ZAppointmentHit) {
                        ZAppointmentHit as = (ZAppointmentHit) hit;
                        String fid = folderIdMapper.get(as.getFolderId());
                        if (fid == null) {
                            fid = as.getFolderId();
                        }
                        ZApptSummaryResult r = folder2List.get(fid);
                        if (r == null) {
                            List<ZAppointmentHit> appts = new ArrayList<ZAppointmentHit>();
                            r = new ZApptSummaryResult(startMsec, endMsec, fid, timeZone, appts, query);
                            summaries.add(r);
                            folder2List.put(fid, r);
                        }
                        r.getAppointments().add(as);
                    }
                }
                List<ZSearchHit> hits = result.getHits();
                if (result.hasMore() && !hits.isEmpty()) {
                    params.setOffset(offset);
                } else {
                    break;
                }
            }
            for (ZApptSummaryResult r : folder2List.values()) {
                mApptSummaryCache.add(r, timeZone);
            }
        }
        return summaries;
    }

    public static class ZAppointmentResult {

        private final String mCalItemId;
        private final String mInviteId;

        public ZAppointmentResult(Element response) {
            mCalItemId = response.getAttribute(MailConstants.A_CAL_ID, null);
            mInviteId = response.getAttribute(MailConstants.A_CAL_INV_ID, null);
        }

        public String getCalItemId() {
            return mCalItemId;
        }

        public String getInviteId() {
            return mInviteId;
        }
    }

    public ZAppointmentResult createAppointment(String folderId, String flags, ZOutgoingMessage message,
            ZInvite invite, String optionalUid) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_APPOINTMENT_REQUEST);

        //noinspection UnusedDeclaration
        Element mEl = getMessageElement(req, message, null);

        if (flags != null) {
            mEl.addAttribute(MailConstants.A_FLAGS, flags);
        }

        if (folderId != null) {
            mEl.addAttribute(MailConstants.A_FOLDER, folderId);
        }

        Element invEl = invite.toElement(mEl);
        if (optionalUid != null) {
            invEl.addAttribute(MailConstants.A_UID, optionalUid);
        }

        return new ZAppointmentResult(invoke(req));
    }

    public ZAppointmentResult createAppointmentException(String id, String component, ZDateTime exceptionId,
            ZOutgoingMessage message, ZInvite invite, String optionalUid) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_APPOINTMENT_EXCEPTION_REQUEST);

        req.addAttribute(MailConstants.A_ID, id);
        req.addAttribute(MailConstants.E_INVITE_COMPONENT, component);

        Element mEl = getMessageElement(req, message, null);

        Element invEl = invite.toElement(mEl);
        Element compEl = invEl.getElement(MailConstants.E_INVITE_COMPONENT);
        exceptionId.toElement(MailConstants.E_CAL_EXCEPTION_ID, compEl);

        if (optionalUid != null) {
            invEl.addAttribute(MailConstants.A_UID, optionalUid);
        }

        return new ZAppointmentResult(invoke(req));
    }

    public ZAppointmentResult modifyAppointment(String id, String component, ZDateTime exceptionId,
            ZOutgoingMessage message, ZInvite invite) throws ServiceException {
        Element req = newRequestElement(MailConstants.MODIFY_APPOINTMENT_REQUEST);

        req.addAttribute(MailConstants.A_ID, id);
        req.addAttribute(MailConstants.E_INVITE_COMPONENT, component);

        Element mEl = getMessageElement(req, message, null);

        Element invEl = invite.toElement(mEl);

        if (exceptionId != null) {
            Element compEl = invEl.getElement(MailConstants.E_INVITE_COMPONENT);
            exceptionId.toElement(MailConstants.E_CAL_EXCEPTION_ID, compEl);
        }

        return new ZAppointmentResult(invoke(req));
    }

    public enum CancelRange {
        THISANDFUTURE, THISANDPRIOR
    }

    public void cancelAppointment(String id, String component, ZTimeZone tz, ZDateTime instance, CancelRange range,
            ZOutgoingMessage message) throws ServiceException {
        Element req = newRequestElement(MailConstants.CANCEL_APPOINTMENT_REQUEST);

        req.addAttribute(MailConstants.A_ID, id);
        req.addAttribute(MailConstants.E_INVITE_COMPONENT, component);

        if (tz != null) {
            tz.toElement(req);
        }

        if (instance != null) {
            Element instEl = instance.toElement(MailConstants.E_INSTANCE, req);
            if (range != null) {
                instEl.addAttribute(MailConstants.A_CAL_RANGE, range.name());
            }
        }

        if (message != null) {
            getMessageElement(req, message, null);
        }

        mMessageCache.remove(id);

        invoke(req);
    }

    public static class ZSendInviteReplyResult {

        public static final String STATUS_OK = "OK";
        public static final String STATUS_OLD = "OLD";
        public static final String STATUS_ALREADY_REPLIED = "ALREADY-REPLIED";
        public static final String STATUS_FAIL = "FAIL";

        private final String mStatus;

        public ZSendInviteReplyResult(Element response) {
            mStatus = response.getAttribute(MailConstants.A_STATUS, "OK");
        }

        public String getStatus() {
            return mStatus;
        }

        public boolean isOk() {
            return mStatus.equals(STATUS_OK);
        }

        public boolean isOld() {
            return mStatus.equals(STATUS_OLD);
        }

        public boolean isAlreadyReplied() {
            return mStatus.equals(STATUS_ALREADY_REPLIED);
        }

        public boolean isFail() {
            return mStatus.equals(STATUS_FAIL);
        }
    }

    public enum ReplyVerb {

        ACCEPT, COMPLETED, DECLINE, DELEGATED, TENTATIVE;

        public static ReplyVerb fromString(String s) throws ServiceException {
            try {
                return ReplyVerb.valueOf(s);
            } catch (IllegalArgumentException e) {
                throw ZClientException.CLIENT_ERROR(
                        "invalid reply verb: " + s + ", valid values: " + Arrays.asList(ReplyVerb.values()), e);
            }
        }
    }

    public ZSendInviteReplyResult sendInviteReply(String id, String component, ReplyVerb verb,
            boolean updateOrganizer, ZTimeZone tz, ZDateTime instance, ZOutgoingMessage message)
            throws ServiceException {
        Element req = newRequestElement(MailConstants.SEND_INVITE_REPLY_REQUEST);

        req.addAttribute(MailConstants.A_ID, id);
        req.addAttribute(MailConstants.A_CAL_COMPONENT_NUM, component);
        req.addAttribute(MailConstants.A_VERB, verb.name());
        req.addAttribute(MailConstants.A_CAL_UPDATE_ORGANIZER, updateOrganizer);

        if (tz != null) {
            tz.toElement(req);
        }

        if (instance != null) {
            instance.toElement(MailConstants.E_CAL_EXCEPTION_ID, req);
        }

        if (message != null) {
            getMessageElement(req, message, null);
        }

        mMessageCache.remove(id);

        return new ZSendInviteReplyResult(invoke(req));
    }

    public ZAppointment getAppointment(String id) throws ServiceException {
        Element req = newRequestElement(MailConstants.GET_APPOINTMENT_REQUEST);
        req.addAttribute(MailConstants.A_ID, id);
        req.addAttribute(MailConstants.A_SYNC, true);
        return new ZAppointment(invoke(req).getElement(MailConstants.E_APPOINTMENT));
    }

    public com.zimbra.soap.mail.type.CalendarItemInfo getRemoteCalItemByUID(String requestedAccountId, String uid,
            boolean includeInvites, boolean includeContent) throws ServiceException {
        GetAppointmentResponse resp = invokeJaxbOnTargetAccount(
                GetAppointmentRequest.createForUidInvitesContent(uid, includeInvites, includeContent),
                requestedAccountId);
        return resp == null ? null : resp.getItem();
    }

    public void clearMessageCache() {
        mMessageCache.clear();
    }

    public void clearContactCache() {
        mContactCache.clear();
    }

    public static class ZImportAppointmentsResult {

        private final String mIds;
        private final long mCount;

        public ZImportAppointmentsResult(Element response) throws ServiceException {
            mIds = response.getAttribute(MailConstants.A_ID, null);
            mCount = response.getAttributeLong(MailConstants.A_NUM);
        }

        public String getIds() {
            return mIds;
        }

        public long getCount() {
            return mCount;
        }
    }

    public static final String APPOINTMENT_IMPORT_TYPE_ICS = "ics";

    public ZImportAppointmentsResult importAppointments(String folderId, String type, String attachmentId)
            throws ServiceException {
        Element req = newRequestElement(MailConstants.IMPORT_APPOINTMENTS_REQUEST);
        req.addAttribute(MailConstants.A_CONTENT_TYPE, type);
        req.addAttribute(MailConstants.A_FOLDER, folderId);
        Element content = req.addElement(MailConstants.E_CONTENT);
        content.addAttribute(MailConstants.A_ATTACHMENT_ID, attachmentId);
        return new ZImportAppointmentsResult(invoke(req).getElement(MailConstants.E_APPOINTMENT));
    }

    public static class ZGetFreeBusyResult {
        private final String mId;
        private final List<ZFreeBusyTimeSlot> mTimeSlots;

        public ZGetFreeBusyResult(String id, List<ZFreeBusyTimeSlot> timeSlots) {
            mId = id;
            mTimeSlots = timeSlots;
        }

        public String getId() {
            return mId;
        }

        public List<ZFreeBusyTimeSlot> getTimeSlots() {
            return mTimeSlots;
        }
    }

    public enum ZFreeBusySlotType {
        FREE, BUSY, TENTATIVE, UNAVAILABLE, NODATA;

        public static ZFreeBusySlotType fromString(String s) throws ServiceException {
            try {
                return ZFreeBusySlotType.valueOf(s);
            } catch (IllegalArgumentException e) {
                throw ZClientException.CLIENT_ERROR("invalid free busy slot type: " + s + ", valid values: "
                        + Arrays.asList(ZFreeBusySlotType.values()), e);
            }
        }
    }

    public static class ZFreeBusyTimeSlot {
        private final ZFreeBusySlotType mType;
        private final long mStart;
        private final long mEnd;

        public ZFreeBusyTimeSlot(ZFreeBusySlotType type, long start, long end) {
            mType = type;
            mStart = start;
            mEnd = end;
        }

        public ZFreeBusySlotType getType() {
            return mType;
        }

        public long getStartTime() {
            return mStart;
        }

        public long getEndTime() {
            return mEnd;
        }
    }

    public List<ZGetFreeBusyResult> getFreeBusy(String email, long startTime, long endTime, int folder)
            throws ServiceException {
        Element req = newRequestElement(MailConstants.GET_FREE_BUSY_REQUEST);
        req.addAttribute(MailConstants.A_CAL_START_TIME, startTime);
        req.addAttribute(MailConstants.A_CAL_END_TIME, endTime);
        Element userElem = req.addElement(MailConstants.E_FREEBUSY_USER);
        userElem.addAttribute(MailConstants.A_NAME, email);
        if (folder != CALENDAR_FOLDER_ALL) {
            userElem.addAttribute(MailConstants.A_FOLDER, folder);
        }
        Element resp = invoke(req);
        List<ZGetFreeBusyResult> result = new ArrayList<ZGetFreeBusyResult>();
        for (Element user : resp.listElements(MailConstants.E_FREEBUSY_USER)) {
            String userId = user.getAttribute(MailConstants.A_ID);
            List<ZFreeBusyTimeSlot> slots = new ArrayList<ZFreeBusyTimeSlot>();
            for (Element slot : user.listElements()) {
                ZFreeBusySlotType type;
                if (slot.getName().equals(MailConstants.E_FREEBUSY_BUSY)) {
                    type = ZFreeBusySlotType.BUSY;
                } else if (slot.getName().equals(MailConstants.E_FREEBUSY_BUSY_TENTATIVE)) {
                    type = ZFreeBusySlotType.TENTATIVE;
                } else if (slot.getName().equals(MailConstants.E_FREEBUSY_BUSY_UNAVAILABLE)) {
                    type = ZFreeBusySlotType.UNAVAILABLE;
                } else if (slot.getName().equals(MailConstants.E_FREEBUSY_NODATA)) {
                    type = ZFreeBusySlotType.NODATA;
                } else {
                    type = ZFreeBusySlotType.FREE;
                }
                slots.add(new ZFreeBusyTimeSlot(type, slot.getAttributeLong(MailConstants.A_CAL_START_TIME),
                        slot.getAttributeLong(MailConstants.A_CAL_END_TIME)));
            }
            result.add(new ZGetFreeBusyResult(userId, slots));
        }
        return result;
    }

    public List<ZAppointmentHit> createAppointmentHits(List<ZFreeBusyTimeSlot> slots) {
        List<ZAppointmentHit> result = new ArrayList<ZAppointmentHit>();
        for (ZFreeBusyTimeSlot slot : slots) {
            switch (slot.getType()) {
            case BUSY:
            case TENTATIVE:
            case UNAVAILABLE:
            case NODATA:
                result.add(new ZAppointmentHit(slot));
                break;
            }
        }
        return result;
    }

    /* tasks */

    public ZAppointmentResult createTask(String folderId, String flags, ZOutgoingMessage message, ZInvite invite,
            String optionalUid) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_TASK_REQUEST);

        //noinspection UnusedDeclaration
        Element mEl = getMessageElement(req, message, null);

        if (flags != null) {
            mEl.addAttribute(MailConstants.A_FLAGS, flags);
        }

        if (folderId != null) {
            mEl.addAttribute(MailConstants.A_FOLDER, folderId);
        }

        Element invEl = invite.toElement(mEl);
        if (optionalUid != null) {
            invEl.addAttribute(MailConstants.A_UID, optionalUid);
        }

        return new ZAppointmentResult(invoke(req));
    }

    public ZAppointmentResult createTaskException(String id, String component, ZDateTime exceptionId,
            ZOutgoingMessage message, ZInvite invite, String optionalUid) throws ServiceException {
        Element req = newRequestElement(MailConstants.CREATE_TASK_EXCEPTION_REQUEST);

        req.addAttribute(MailConstants.A_ID, id);
        req.addAttribute(MailConstants.E_INVITE_COMPONENT, component);

        Element mEl = getMessageElement(req, message, null);

        Element invEl = invite.toElement(mEl);
        Element compEl = invEl.getElement(MailConstants.E_INVITE_COMPONENT);
        exceptionId.toElement(MailConstants.E_CAL_EXCEPTION_ID, compEl);

        if (optionalUid != null) {
            invEl.addAttribute(MailConstants.A_UID, optionalUid);
        }

        return new ZAppointmentResult(invoke(req));
    }

    public ZAppointmentResult modifyTask(String id, String component, ZDateTime exceptionId,
            ZOutgoingMessage message, ZInvite invite) throws ServiceException {
        Element req = newRequestElement(MailConstants.MODIFY_TASK_REQUEST);

        req.addAttribute(MailConstants.A_ID, id);
        req.addAttribute(MailConstants.E_INVITE_COMPONENT, component);

        Element mEl = getMessageElement(req, message, null);

        Element invEl = invite.toElement(mEl);

        if (exceptionId != null) {
            Element compEl = invEl.getElement(MailConstants.E_INVITE_COMPONENT);
            exceptionId.toElement(MailConstants.E_CAL_EXCEPTION_ID, compEl);
        }

        return new ZAppointmentResult(invoke(req));
    }

    public void cancelTask(String id, String component, ZTimeZone tz, ZDateTime instance, CancelRange range,
            ZOutgoingMessage message) throws ServiceException {
        Element req = newRequestElement(MailConstants.CANCEL_TASK_REQUEST);

        req.addAttribute(MailConstants.A_ID, id);
        req.addAttribute(MailConstants.E_INVITE_COMPONENT, component);

        if (tz != null) {
            tz.toElement(req);
        }

        if (instance != null) {
            Element instEl = instance.toElement(MailConstants.E_INSTANCE, req);
            if (range != null) {
                instEl.addAttribute(MailConstants.A_CAL_RANGE, range.name());
            }
        }

        if (message != null) {
            getMessageElement(req, message, null);
        }

        mMessageCache.remove(id);

        invoke(req);
    }

    public synchronized List<ZPhoneAccount> getAllPhoneAccounts() throws ServiceException {
        if (mPhoneAccounts == null) {
            ArrayList<ZPhoneAccount> accounts = new ArrayList<ZPhoneAccount>();
            mPhoneAccountMap = new HashMap<String, ZPhoneAccount>();
            Element req = newRequestElement(VoiceConstants.GET_VOICE_INFO_REQUEST);
            Element response = invoke(req);
            Element storePrincipalEl = response.getElement(VoiceConstants.E_STOREPRINCIPAL);
            mVoiceStorePrincipal = storePrincipalEl.clone();
            List<Element> phoneElements = response.listElements(VoiceConstants.E_PHONE);
            for (Element element : phoneElements) {
                ZPhoneAccount account = new ZPhoneAccount(element, this);
                accounts.add(account);
                mPhoneAccountMap.put(account.getPhone().getName(), account);
            }
            mPhoneAccounts = Collections.unmodifiableList(accounts);
        }
        return mPhoneAccounts;
    }

    private void setVoiceStorePrincipal(Element req) {
        req.addElement(mVoiceStorePrincipal.clone());
    }

    public ZPhoneAccount getPhoneAccount(String name) throws ServiceException {
        getAllPhoneAccounts(); // Make sure they're loaded.
        return mPhoneAccountMap.get(name);
    }

    public String uploadVoiceMail(String phone, String id) throws ServiceException {
        Element req = newRequestElement(VoiceConstants.UPLOAD_VOICE_MAIL_REQUEST);
        setVoiceStorePrincipal(req);
        Element actionEl = req.addElement(VoiceConstants.E_VOICEMSG);
        actionEl.addAttribute(MailConstants.A_ID, id);
        actionEl.addAttribute(VoiceConstants.A_PHONE, phone);
        Element response = invoke(req);
        return response.getElement(VoiceConstants.E_UPLOAD).getAttribute(MailConstants.A_ID);
    }

    public void loadCallFeatures(ZCallFeatures features) throws ServiceException {
        Element req = newRequestElement(VoiceConstants.GET_VOICE_FEATURES_REQUEST);
        setVoiceStorePrincipal(req);
        Element phoneEl = req.addElement(VoiceConstants.E_PHONE);
        phoneEl.addAttribute(MailConstants.A_NAME, features.getPhone().getName());
        Collection<ZCallFeature> featureList = features.getSubscribedFeatures();
        for (ZCallFeature feature : featureList) {
            phoneEl.addElement(feature.getName());
        }
        Element response = invoke(req);

        phoneEl = response.getElement(VoiceConstants.E_PHONE);
        for (ZCallFeature feature : featureList) {
            String name = feature.getName();
            Element element = phoneEl.getOptionalElement(name);
            if (element != null) {
                feature.fromElement(element);
            }
        }
    }

    public void saveCallFeatures(ZCallFeatures newFeatures) throws ServiceException {
        // Build up the soap request.
        Element req = newRequestElement(VoiceConstants.MODIFY_VOICE_FEATURES_REQUEST);
        setVoiceStorePrincipal(req);
        Element phoneEl = req.addElement(VoiceConstants.E_PHONE);
        phoneEl.addAttribute(MailConstants.A_NAME, newFeatures.getPhone().getName());
        Collection<ZCallFeature> list = newFeatures.getAllFeatures();
        for (ZCallFeature newFeature : list) {
            Element element = phoneEl.addElement(newFeature.getName());
            newFeature.toElement(element);
        }
        invoke(req);

        // Copy new data into cache.
        ZPhoneAccount account = getPhoneAccount(newFeatures.getPhone().getName());
        ZCallFeatures oldFeatures = account.getCallFeatures();
        for (ZCallFeature newFeature : list) {
            ZCallFeature oldFeature = oldFeatures.getFeature(newFeature.getName());
            oldFeature.assignFrom(newFeature);
        }
    }

    public ZActionResult trashVoiceMail(String phone, String id) throws ServiceException {
        return moveVoiceMail(phone, id, VoiceConstants.FID_TRASH);
    }

    public ZActionResult moveVoiceMail(String phone, String id, int folderId) throws ServiceException {
        ZActionResult result = doAction(voiceAction("move", phone, id, folderId));
        ZModifyEvent event = new ZModifyVoiceMailItemFolderEvent(Integer.toString(folderId));
        handleEvent(event);
        refreshVoiceMailInbox(phone);
        return result;
    }

    public ZActionResult emptyVoiceMailTrash(String phone, String folderId) throws ServiceException {
        ZActionResult result = doAction(voiceAction("empty", phone, folderId, 0));

        // Don't use a delete event, since it deals with the ids of the deleted items and we don't have those.
        // Instead just clear the cache that we know know of that might need to be rebuilt.
        mSearchPagerCache.clear(null);
        return result;
    }

    /** Makes a server call to get updated message/unheard counts for the folders */
    private void refreshVoiceMailInbox(String phone) throws ServiceException {
        ZPhoneAccount account = getPhoneAccount(phone);
        if (account == null) {
            return;
        }

        Element req = newRequestElement(VoiceConstants.GET_VOICE_FOLDER_REQUEST);
        setVoiceStorePrincipal(req);
        Element phoneEl = req.addElement(VoiceConstants.E_PHONE);
        phoneEl.addAttribute(MailConstants.A_NAME, phone);
        Element response = invoke(req);

        Element phoneResponse = response.getElement(VoiceConstants.E_PHONE);
        if (phoneResponse != null) {
            ZFolder rootFolder = account.getRootFolder();
            Element rootEl = phoneResponse.getElement(MailConstants.E_FOLDER);
            for (Element childEl : rootEl.listElements(MailConstants.E_FOLDER)) {
                String name = childEl.getAttribute(MailConstants.A_NAME);
                ZFolder childFolder = rootFolder.getSubFolderByPath(name);
                if (childFolder != null) {
                    childFolder.setUnreadCount((int) childEl.getAttributeLong(MailConstants.A_UNREAD, 0));
                    childFolder.setMessageCount((int) childEl.getAttributeLong(MailConstants.A_NUM, 0));
                }
            }
        }
    }

    public ZActionResult markVoiceMailHeard(String phone, String idList, boolean heard) throws ServiceException {
        String op = heard ? "read" : "!read";
        ZActionResult result = doAction(voiceAction(op, phone, idList, 0));
        int changeCount = 0;
        boolean needRefresh = false;
        for (String id : sCOMMA.split(idList)) {
            ZModifyVoiceMailItemEvent event = new ZModifyVoiceMailItemEvent(id, heard);
            handleEvent(event);
            if (event.getMadeChange()) {
                changeCount++;
            } else {
                needRefresh = true;
            }
        }
        if (needRefresh) {
            refreshVoiceMailInbox(phone);
        } else if (changeCount > 0) {
            ZPhoneAccount account = getPhoneAccount(phone);
            ZFolder inbox = account.getRootFolder().getSubFolderByPath(VoiceConstants.FNAME_VOICEMAILINBOX);
            int diff = heard ? -changeCount : changeCount;
            inbox.setUnreadCount(inbox.getUnreadCount() + diff);
        }
        return result;
    }

    private Element voiceAction(String op, String phone, String id, int folderId) {
        Element req = newRequestElement(VoiceConstants.VOICE_MSG_ACTION_REQUEST);
        setVoiceStorePrincipal(req);
        Element actionEl = req.addElement(MailConstants.E_ACTION);
        actionEl.addAttribute(MailConstants.A_ID, id);
        actionEl.addAttribute(MailConstants.A_OPERATION, op);
        actionEl.addAttribute(VoiceConstants.A_PHONE, phone);
        if (folderId != 0) {
            actionEl.addAttribute(MailConstants.A_FOLDER, Integer.toString(folderId) + '-' + phone);
        }
        return actionEl;
    }

    public synchronized ZContactByPhoneCache.ContactPhone getContactByPhone(String phone) throws ServiceException {
        if (mContactByPhoneCache == null) {
            mContactByPhoneCache = new ZContactByPhoneCache();
            mHandlers.add(mContactByPhoneCache);
        }
        return mContactByPhoneCache.getByPhone(phone, this);
    }

    private void updateSigs() {
        try {
            if (mGetInfoResult != null) {
                mGetInfoResult.setSignatures(getSignatures());
            }
        } catch (ServiceException e) {
            /* ignore */
        }
    }

    public synchronized List<String> saveAttachmentsToBriefcase(String mid, String[] partIds, String folderId)
            throws ServiceException {
        if (partIds == null || partIds.length <= 0) {
            return null;
        }
        List<String> docIds = new ArrayList<String>();
        for (String pid : partIds) { //!TODO We should do batch request for performance
            Element req = newRequestElement(MailConstants.SAVE_DOCUMENT_REQUEST);
            Element doc = req.addElement(MailConstants.E_DOC).addAttribute(MailConstants.A_FOLDER, folderId);
            Element m = doc.addElement(MailConstants.E_MSG).addAttribute(MailConstants.A_ID, mid);
            m.addAttribute(MailConstants.A_PART, pid);
            Element rDoc = invoke(req).getElement(MailConstants.E_DOC);
            if (rDoc == null) {
                continue;
            }
            docIds.add(rDoc.getAttribute(MailConstants.A_ID));
        }
        return docIds;
    }

    public synchronized String createSignature(ZSignature signature) throws ServiceException {
        Element req = newRequestElement(AccountConstants.CREATE_SIGNATURE_REQUEST);
        signature.toElement(req);
        String id = invoke(req).getElement(AccountConstants.E_SIGNATURE).getAttribute(AccountConstants.A_ID);
        updateSigs();
        return id;
    }

    public List<ZSignature> getSignatures() throws ServiceException {
        GetSignaturesResponse res = invokeJaxb(new GetSignaturesRequest());
        return ListUtil.newArrayList(res.getSignatures(), SoapConverter.FROM_SOAP_SIGNATURE);
    }

    public synchronized void deleteSignature(String id) throws ServiceException {
        Element req = newRequestElement(AccountConstants.DELETE_SIGNATURE_REQUEST);
        req.addElement(AccountConstants.E_SIGNATURE).addAttribute(AccountConstants.A_ID, id);
        invoke(req);
        updateSigs();
    }

    public synchronized void modifySignature(ZSignature signature) throws ServiceException {
        Element req = newRequestElement(AccountConstants.MODIFY_SIGNATURE_REQUEST);
        signature.toElement(req);
        invoke(req);
        updateSigs();
    }

    public List<ZAce> getRights(String[] rights) throws ServiceException {
        Element req = newRequestElement(AccountConstants.GET_RIGHTS_REQUEST);
        if (rights != null && rights.length > 0) {
            for (String right : rights) {
                req.addElement(AccountConstants.E_ACE).addAttribute(AccountConstants.A_RIGHT, right);
            }
        }
        Element resp = invoke(req);
        List<ZAce> result = new ArrayList<ZAce>();
        for (Element ace : resp.listElements(AccountConstants.E_ACE)) {
            result.add(new ZAce(ace));
        }
        return result;
    }

    public List<ZAce> grantRight(ZAce ace) throws ServiceException {
        Element req = newRequestElement(AccountConstants.GRANT_RIGHTS_REQUEST);
        ace.toElement(req);
        Element resp = invoke(req);
        List<ZAce> result = new ArrayList<ZAce>();
        for (Element a : resp.listElements(AccountConstants.E_ACE)) {
            result.add(new ZAce(a));
        }
        return result;
    }

    public List<ZAce> revokeRight(ZAce ace) throws ServiceException {
        Element req = newRequestElement(AccountConstants.REVOKE_RIGHTS_REQUEST);
        ace.toElement(req);
        Element resp = invoke(req);
        List<ZAce> result = new ArrayList<ZAce>();
        for (Element a : resp.listElements(AccountConstants.E_ACE)) {
            result.add(new ZAce(a));
        }
        return result;
    }

    public boolean checkRights(String name, List<String> rights) throws ServiceException {
        Element req = newRequestElement(AccountConstants.CHECK_RIGHTS_REQUEST);
        Element eTarget = req.addElement(AccountConstants.E_TARGET);
        eTarget.addAttribute(AccountConstants.A_TARGET_TYPE, "account");
        eTarget.addAttribute(AccountConstants.A_TARGET_BY, "name");
        eTarget.addAttribute(AccountConstants.A_KEY, name);

        for (String right : rights) {
            Element eRight = eTarget.addElement(AccountConstants.E_RIGHT);
            eRight.setText(right);
        }
        Element resp = invoke(req);
        Element eTargetResp = resp.getElement(AccountConstants.E_TARGET);
        boolean allow = eTargetResp.getAttributeBool(AccountConstants.A_ALLOW);

        return allow;
    }

    @Override
    public String toString() {
        try {
            return String.format("[ZMailbox %s]", getName());
        } catch (ServiceException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public ZJSONObject toZJSONObject() throws JSONException {
        try {
            ZJSONObject jo = new ZJSONObject();
            jo.put("name", getName());
            jo.put("size", mSize);
            jo.put("hasTags", hasTags());
            jo.put("userRoot", getUserRoot());
            jo.put("tags", getAllTags());
            return jo;
        } catch (ServiceException se) {
            throw new JSONException(se);
        }
    }

    public String dump() {
        return ZJSONObject.toString(this);
    }

    public ZSearchContext searchContext(ZSearchParams params) {
        return new ZSearchContext(params, this);
    }

    public ZSearchContext searchContext(String query) {
        return new ZSearchContext(new ZSearchParams(query), this);
    }

    public void logout() throws ZClientException {
        EndSessionRequest logout = new EndSessionRequest();
        logout.setLogOff(true);
        try {
            invokeJaxb(logout);
        } catch (ServiceException e) {
            //do not thrown an exception if the authtoken has already expired as when user us redirected to logout tag when authtoken expires.
            if (!ServiceException.AUTH_EXPIRED.equals(e.getCode())) {
                throw ZClientException.CLIENT_ERROR("Failed to log out", e);
            }
        }
    }

    private static final int ADMIN_PORT = LC.zimbra_admin_service_port.intValue();

    public static String resolveUrl(String url, boolean isAdmin) throws ZClientException {
        try {
            URI uri = new URI(url);

            if (isAdmin && uri.getPort() == -1) {
                uri = new URI("https", uri.getUserInfo(), uri.getHost(), ADMIN_PORT, uri.getPath(), uri.getQuery(),
                        uri.getFragment());
                url = uri.toString();
            }

            String service = (uri.getPort() == ADMIN_PORT) ? AdminConstants.ADMIN_SERVICE_URI
                    : AccountConstants.USER_SERVICE_URI;
            if (uri.getPath() == null || uri.getPath().length() <= 1) {
                if (url.charAt(url.length() - 1) == '/') {
                    url = url.substring(0, url.length() - 1) + service;
                } else {
                    url = url + service;
                }
            }
            return url;
        } catch (URISyntaxException e) {
            throw ZClientException.CLIENT_ERROR("invalid URL: " + url, e);
        }
    }

    /**
     * Given a path, resolves as much of the path as possible and returns the folder and the unmatched part.
     *
     * E.G. if the path is "/foo/bar/baz/gub" and this mailbox has a Folder at "/foo/bar" -- this API returns
     * a Pair containing that Folder and the unmatched part "baz/gub".
     *
     * If the returned folder is a ZMountpoint, then it can be assumed that the remaining part is a subfolder in
     * the remote mailbox.
     *
     * @param baseFolderItemId Folder to start from (pass Mailbox.ID_FOLDER_ROOT as String to start from the root)
     * @throws ServiceException if the folder with {@code startingFolderId} does not exist or {@code path} is
     * {@code null} or empty.
     */
    public Pair<ZFolder, String> getFolderByPathLongestMatch(String baseFolderItemId, String path)
            throws ServiceException {
        if (Strings.isNullOrEmpty(path)) {
            throw ServiceException.INVALID_REQUEST("no such folder " + path, null);
        }
        ZFolder folder = getFolderById(baseFolderItemId);
        assert (folder != null);
        path = CharMatcher.is('/').trimFrom(path); // trim leading and trailing '/'
        if (path.isEmpty()) { // relative root to the base folder
            return new Pair<ZFolder, String>(folder, null);
        }
        String unmatched = null;
        String[] segments = path.split("/");
        for (int i = 0; i < segments.length; i++) {
            ZFolder subfolder = folder.getSubFolderByPath(segments[i]);
            if (subfolder == null) {
                unmatched = StringUtil.join("/", segments, i, segments.length - i);
                break;
            }
            folder = subfolder;
        }
        return new Pair<ZFolder, String>(folder, unmatched);
    }

    public EnableTwoFactorAuthResponse enableTwoFactorAuth(String password, TOTPAuthenticator auth)
            throws ServiceException {
        EnableTwoFactorAuthRequest req = new EnableTwoFactorAuthRequest();
        req.setName(getName());
        req.setPassword(password);
        EnableTwoFactorAuthResponse resp = invokeJaxb(req);
        String secret = resp.getSecret();
        long timestamp = System.currentTimeMillis() / 1000;
        String totp = auth.generateCode(secret, timestamp, Encoding.BASE32);
        req.setTwoFactorCode(totp);
        req.setAuthToken(resp.getAuthToken());
        resp = invokeJaxb(req);
        resp.setSecret(secret);
        initAuthToken(new ZAuthToken(resp.getAuthToken().getValue()));
        return resp;
    }

    public DisableTwoFactorAuthResponse disableTwoFactorAuth(String password) throws ServiceException {
        DisableTwoFactorAuthRequest req = new DisableTwoFactorAuthRequest();
        return invokeJaxb(req);
    }

    public SoapHttpTransport getTransport() {
        return mTransport;
    }
}