/* * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway * Copyright (C) 2009 Mickael Guessant * * 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; either version 2 * of the License, or (at your option) any later version. * * 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, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ package davmail.exchange; import davmail.BundleMessage; import davmail.Settings; import davmail.exception.DavMailAuthenticationException; import davmail.exception.DavMailException; import davmail.exception.WebdavNotAvailableException; import davmail.http.DavGatewayHttpClientFacade; import davmail.http.DavGatewayOTPPrompt; import davmail.ui.NotificationDialog; import davmail.util.StringUtil; import org.apache.commons.httpclient.*; import org.apache.commons.httpclient.methods.GetMethod; import org.apache.commons.httpclient.methods.PostMethod; import org.apache.commons.httpclient.params.HttpClientParams; import org.apache.commons.httpclient.util.URIUtil; import org.apache.log4j.Logger; import org.htmlcleaner.CommentNode; import org.htmlcleaner.ContentNode; import org.htmlcleaner.HtmlCleaner; import org.htmlcleaner.TagNode; import javax.imageio.ImageIO; import javax.mail.MessagingException; import javax.mail.internet.*; import javax.mail.util.SharedByteArrayInputStream; import java.awt.image.BufferedImage; import java.io.*; import java.net.ConnectException; import java.net.NoRouteToHostException; import java.net.UnknownHostException; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.*; /** * Exchange session through Outlook Web Access (DAV) */ public abstract class ExchangeSession { protected static final Logger LOGGER = Logger.getLogger("davmail.exchange.ExchangeSession"); /** * Reference GMT timezone to format dates */ public static final SimpleTimeZone GMT_TIMEZONE = new SimpleTimeZone(0, "GMT"); protected static final Set USER_NAME_FIELDS = new HashSet(); static { USER_NAME_FIELDS.add("username"); USER_NAME_FIELDS.add("txtUserName"); USER_NAME_FIELDS.add("userid"); USER_NAME_FIELDS.add("SafeWordUser"); USER_NAME_FIELDS.add("user_name"); } protected static final Set PASSWORD_FIELDS = new HashSet(); static { PASSWORD_FIELDS.add("password"); PASSWORD_FIELDS.add("txtUserPass"); PASSWORD_FIELDS.add("pw"); PASSWORD_FIELDS.add("basicPassword"); } protected static final Set TOKEN_FIELDS = new HashSet(); static { TOKEN_FIELDS.add("SafeWordPassword"); TOKEN_FIELDS.add("passcode"); } protected static final int FREE_BUSY_INTERVAL = 15; protected static final String PUBLIC_ROOT = "/public/"; protected static final String CALENDAR = "calendar"; protected static final String TASKS = "tasks"; /** * Contacts folder logical name */ public static final String CONTACTS = "contacts"; protected static final String ADDRESSBOOK = "addressbook"; protected static final String INBOX = "INBOX"; protected static final String LOWER_CASE_INBOX = "inbox"; protected static final String SENT = "Sent"; protected static final String SENDMSG = "##DavMailSubmissionURI##"; protected static final String DRAFTS = "Drafts"; protected static final String TRASH = "Trash"; protected static final String JUNK = "Junk"; protected static final String UNSENT = "Unsent Messages"; static { // Adjust Mime decoder settings System.setProperty("mail.mime.ignoreunknownencoding", "true"); System.setProperty("mail.mime.decodetext.strict", "false"); } protected String publicFolderUrl; /** * Base user mailboxes path (used to select folder) */ protected String mailPath; protected String rootPath; protected String email; protected String alias; /** * Lower case Caldav path to current user mailbox. * /users/email */ protected String currentMailboxPath; protected final HttpClient httpClient; protected String userName; /** * A OTP pre-auth page may require a different username. */ private String preAuthUsername; protected String serverVersion; protected static final String YYYY_MM_DD_HH_MM_SS = "yyyy/MM/dd HH:mm:ss"; private static final String YYYYMMDD_T_HHMMSS_Z = "yyyyMMdd'T'HHmmss'Z'"; protected static final String YYYY_MM_DD_T_HHMMSS_Z = "yyyy-MM-dd'T'HH:mm:ss'Z'"; private static final String YYYY_MM_DD = "yyyy-MM-dd"; private static final String YYYY_MM_DD_T_HHMMSS_SSS_Z = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"; /** * Logon form user name fields. */ private final List userNameInputs = new ArrayList(); /** * Logon form password field, default is password. */ private String passwordInput = null; /** * Tells if, during the login navigation, an OTP pre-auth page has been found. */ private boolean otpPreAuthFound = false; /** * Lets the user try again a couple of times to enter the OTP pre-auth key before giving up. */ private int otpPreAuthRetries = 0; /** * Maximum number of times the user can try to input again the OTP pre-auth key before giving up. */ private static final int MAX_OTP_RETRIES = 3; /** * Create an exchange session for the given URL. * The session is established for given userName and password * * @param url Exchange url * @param userName user login name * @param password user password * @throws IOException on error */ public ExchangeSession(String url, String userName, String password) throws IOException { this.userName = userName; try { httpClient = DavGatewayHttpClientFacade.getInstance(url); // set private connection pool DavGatewayHttpClientFacade.createMultiThreadedHttpConnectionManager(httpClient); boolean isBasicAuthentication = isBasicAuthentication(httpClient, url); // clear cookies created by authentication test httpClient.getState().clearCookies(); // The user may have configured an OTP pre-auth username. It is processed // so early because OTP pre-auth may disappear in the Exchange LAN and this // helps the user to not change is account settings in mail client at each network change. if (preAuthUsername == null) { // Searches for the delimiter in configured username for the pre-auth user. // The double-quote is not allowed inside email addresses anyway. int doubleQuoteIndex = this.userName.indexOf('"'); if (doubleQuoteIndex > 0) { preAuthUsername = this.userName.substring(0, doubleQuoteIndex); this.userName = this.userName.substring(doubleQuoteIndex + 1); } else { // No doublequote: the pre-auth user is the full username, or it is not used at all. preAuthUsername = this.userName; } } DavGatewayHttpClientFacade.setCredentials(httpClient, userName, password); // get webmail root url // providing credentials // manually follow redirect HttpMethod method = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, url); if (!this.isAuthenticated(method)) { if (isBasicAuthentication) { int status = method.getStatusCode(); if (status == HttpStatus.SC_UNAUTHORIZED) { method.releaseConnection(); throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED"); } else if (status != HttpStatus.SC_OK) { method.releaseConnection(); throw DavGatewayHttpClientFacade.buildHttpException(method); } // workaround for basic authentication on /exchange and form based authentication at /owa if ("/owa/auth/logon.aspx".equals(method.getPath())) { method = formLogin(httpClient, method, userName, password); } } else { method = formLogin(httpClient, method, userName, password); } } // avoid 401 roundtrips, only if NTLM is disabled and basic authentication enabled if (isBasicAuthentication && !DavGatewayHttpClientFacade.hasNTLMorNegotiate(httpClient)) { httpClient.getParams().setParameter(HttpClientParams.PREEMPTIVE_AUTHENTICATION, true); } buildSessionInfo(method); } catch (DavMailAuthenticationException exc) { LOGGER.error(exc.getMessage()); throw exc; } catch (ConnectException exc) { BundleMessage message = new BundleMessage("EXCEPTION_CONNECT", exc.getClass().getName(), exc.getMessage()); ExchangeSession.LOGGER.error(message); throw new DavMailException("EXCEPTION_DAVMAIL_CONFIGURATION", message); } catch (UnknownHostException exc) { BundleMessage message = new BundleMessage("EXCEPTION_CONNECT", exc.getClass().getName(), exc.getMessage()); ExchangeSession.LOGGER.error(message); throw new DavMailException("EXCEPTION_DAVMAIL_CONFIGURATION", message); } catch (WebdavNotAvailableException exc) { throw exc; } catch (IOException exc) { LOGGER.error(BundleMessage.formatLog("EXCEPTION_EXCHANGE_LOGIN_FAILED", exc)); throw new DavMailException("EXCEPTION_EXCHANGE_LOGIN_FAILED", exc); } LOGGER.debug("Session " + this + " created"); } /** * Format date to exchange search format. * * @param date date object * @return formatted search date */ public abstract String formatSearchDate(Date date); /** * Return standard zulu date formatter. * * @return zulu date formatter */ public static SimpleDateFormat getZuluDateFormat() { SimpleDateFormat dateFormat = new SimpleDateFormat(YYYYMMDD_T_HHMMSS_Z, Locale.ENGLISH); dateFormat.setTimeZone(GMT_TIMEZONE); return dateFormat; } protected static SimpleDateFormat getVcardBdayFormat() { SimpleDateFormat dateFormat = new SimpleDateFormat(YYYY_MM_DD, Locale.ENGLISH); dateFormat.setTimeZone(GMT_TIMEZONE); return dateFormat; } protected static SimpleDateFormat getExchangeZuluDateFormat() { SimpleDateFormat dateFormat = new SimpleDateFormat(YYYY_MM_DD_T_HHMMSS_Z, Locale.ENGLISH); dateFormat.setTimeZone(GMT_TIMEZONE); return dateFormat; } protected static SimpleDateFormat getExchangeZuluDateFormatMillisecond() { SimpleDateFormat dateFormat = new SimpleDateFormat(YYYY_MM_DD_T_HHMMSS_SSS_Z, Locale.ENGLISH); dateFormat.setTimeZone(GMT_TIMEZONE); return dateFormat; } protected static Date parseDate(String dateString) throws ParseException { SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd"); dateFormat.setTimeZone(GMT_TIMEZONE); return dateFormat.parse(dateString); } /** * Test if the session expired. * * @return true this session expired * @throws NoRouteToHostException on error * @throws UnknownHostException on error */ public boolean isExpired() throws NoRouteToHostException, UnknownHostException { boolean isExpired = false; try { getFolder(""); } catch (UnknownHostException exc) { throw exc; } catch (NoRouteToHostException exc) { throw exc; } catch (IOException e) { isExpired = true; } return isExpired; } /** * Test authentication mode : form based or basic. * * @param url exchange base URL * @param httpClient httpClient instance * @return true if basic authentication detected */ protected boolean isBasicAuthentication(HttpClient httpClient, String url) { return DavGatewayHttpClientFacade.getHttpStatus(httpClient, url) == HttpStatus.SC_UNAUTHORIZED; } protected String getAbsoluteUri(HttpMethod method, String path) throws URIException { URI uri = method.getURI(); if (path != null) { // reset query string uri.setQuery(null); if (path.startsWith("/")) { // path is absolute, replace method path uri.setPath(path); } else if (path.startsWith("http://") || path.startsWith("https://")) { return path; } else { // relative path, build new path String currentPath = method.getPath(); int end = currentPath.lastIndexOf('/'); if (end >= 0) { uri.setPath(currentPath.substring(0, end + 1) + path); } else { throw new URIException(uri.getURI()); } } } return uri.getURI(); } protected String getScriptBasedFormURL(HttpMethod initmethod, String pathQuery) throws URIException { URI initmethodURI = initmethod.getURI(); int queryIndex = pathQuery.indexOf('?'); if (queryIndex >= 0) { if (queryIndex > 0) { // update path String newPath = pathQuery.substring(0, queryIndex); if (newPath.startsWith("/")) { // absolute path initmethodURI.setPath(newPath); } else { String currentPath = initmethodURI.getPath(); int folderIndex = currentPath.lastIndexOf('/'); if (folderIndex >= 0) { // replace relative path initmethodURI.setPath(currentPath.substring(0, folderIndex + 1) + newPath); } else { // should not happen initmethodURI.setPath('/' + newPath); } } } initmethodURI.setQuery(pathQuery.substring(queryIndex + 1)); } return initmethodURI.getURI(); } /** * Try to find logon method path from logon form body. * * @param httpClient httpClient instance * @param initmethod form body http method * @return logon method * @throws IOException on error */ protected HttpMethod buildLogonMethod(HttpClient httpClient, HttpMethod initmethod) throws IOException { HttpMethod logonMethod = null; // create an instance of HtmlCleaner HtmlCleaner cleaner = new HtmlCleaner(); // A OTP token authentication form in a previous page could have username fields with different names userNameInputs.clear(); try { TagNode node = cleaner.clean(initmethod.getResponseBodyAsStream()); List forms = node.getElementListByName("form", true); TagNode logonForm = null; // select form if (forms.size() == 1) { logonForm = (TagNode) forms.get(0); } else if (forms.size() > 1) { for (Object form : forms) { if ("logonForm".equals(((TagNode) form).getAttributeByName("name"))) { logonForm = ((TagNode) form); } } } if (logonForm != null) { String logonMethodPath = logonForm.getAttributeByName("action"); // workaround for broken form with empty action if (logonMethodPath != null && logonMethodPath.length() == 0) { logonMethodPath = "/owa/auth.owa"; } logonMethod = new PostMethod(getAbsoluteUri(initmethod, logonMethodPath)); // retrieve lost inputs attached to body List inputList = node.getElementListByName("input", true); for (Object input : inputList) { String type = ((TagNode) input).getAttributeByName("type"); String name = ((TagNode) input).getAttributeByName("name"); String value = ((TagNode) input).getAttributeByName("value"); if ("hidden".equalsIgnoreCase(type) && name != null && value != null) { ((PostMethod) logonMethod).addParameter(name, value); } // custom login form if (USER_NAME_FIELDS.contains(name)) { userNameInputs.add(name); } else if (PASSWORD_FIELDS.contains(name)) { passwordInput = name; } else if ("addr".equals(name)) { // this is not a logon form but a redirect form HttpMethod newInitMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, logonMethod); logonMethod = buildLogonMethod(httpClient, newInitMethod); } else if (TOKEN_FIELDS.contains(name)) { // one time password, ask it to the user ((PostMethod) logonMethod).addParameter(name, DavGatewayOTPPrompt.getOneTimePassword()); } else if ("otc".equals(name)) { // captcha image, get image and ask user String pinsafeUser = getAliasFromLogin(); if (pinsafeUser == null) { pinsafeUser = userName; } GetMethod getMethod = new GetMethod("/PINsafeISAFilter.dll?username=" + pinsafeUser); try { int status = httpClient.executeMethod(getMethod); if (status != HttpStatus.SC_OK) { throw DavGatewayHttpClientFacade.buildHttpException(getMethod); } BufferedImage captchaImage = ImageIO.read(getMethod.getResponseBodyAsStream()); ((PostMethod) logonMethod).addParameter(name, DavGatewayOTPPrompt.getCaptchaValue(captchaImage)); } finally { getMethod.releaseConnection(); } } } } else { List frameList = node.getElementListByName("frame", true); if (frameList.size() == 1) { String src = ((TagNode) frameList.get(0)).getAttributeByName("src"); if (src != null) { LOGGER.debug("Frames detected in form page, try frame content"); initmethod.releaseConnection(); HttpMethod newInitMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, src); logonMethod = buildLogonMethod(httpClient, newInitMethod); } } else { // another failover for script based logon forms (Exchange 2007) List scriptList = node.getElementListByName("script", true); for (Object script : scriptList) { List contents = ((TagNode) script).getChildren(); for (Object content : contents) { if (content instanceof CommentNode) { String scriptValue = ((CommentNode) content).getCommentedContent(); String sUrl = StringUtil.getToken(scriptValue, "var a_sUrl = \"", "\""); String sLgn = StringUtil.getToken(scriptValue, "var a_sLgnQS = \"", "\""); if (sLgn == null) { sLgn = StringUtil.getToken(scriptValue, "var a_sLgn = \"", "\""); } if (sUrl != null && sLgn != null) { String src = getScriptBasedFormURL(initmethod, sLgn + sUrl); LOGGER.debug("Detected script based logon, redirect to form at " + src); HttpMethod newInitMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, src); logonMethod = buildLogonMethod(httpClient, newInitMethod); } } else if (content instanceof ContentNode) { // Microsoft Forefront Unified Access Gateway redirect String scriptValue = ((ContentNode) content).getContent().toString(); String location = StringUtil.getToken(scriptValue, "window.location.replace(\"", "\""); if (location != null) { LOGGER.debug("Post logon redirect to: " + location); logonMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, location); } } } } } } } catch (IOException e) { LOGGER.error("Error parsing login form at " + initmethod.getURI()); } finally { initmethod.releaseConnection(); } return logonMethod; } protected HttpMethod postLogonMethod(HttpClient httpClient, HttpMethod logonMethod, String userName, String password) throws IOException { setAuthFormFields(logonMethod, httpClient, password); // add exchange 2010 PBack cookie in compatibility mode httpClient.getState().addCookie(new Cookie(httpClient.getHostConfiguration().getHost(), "PBack", "0", "/", null, false)); logonMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, logonMethod); // test form based authentication checkFormLoginQueryString(logonMethod); // workaround for post logon script redirect if (!isAuthenticated(logonMethod)) { // try to get new method from script based redirection logonMethod = buildLogonMethod(httpClient, logonMethod); if (logonMethod != null) { if (otpPreAuthFound && otpPreAuthRetries < MAX_OTP_RETRIES) { // A OTP pre-auth page has been found, it is needed to restart the login process. // This applies to both the case the user entered a good OTP code (the usual login process // takes place) and the case the user entered a wrong OTP code (another code will be asked to him). // The user has up to MAX_OTP_RETRIES chances to input a valid OTP key. return postLogonMethod(httpClient, logonMethod, userName, password); } // if logonMethod is not null, try to follow redirection logonMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, logonMethod); checkFormLoginQueryString(logonMethod); // also check cookies if (!isAuthenticated(logonMethod)) { throwAuthenticationFailed(); } } else { // authentication failed throwAuthenticationFailed(); } } // check for language selection form if (logonMethod != null && "/owa/languageselection.aspx".equals(logonMethod.getPath())) { // need to submit form logonMethod = submitLanguageSelectionForm(logonMethod); } return logonMethod; } protected void setAuthFormFields(HttpMethod logonMethod, HttpClient httpClient, String password) throws IllegalArgumentException { String userNameInput; if (userNameInputs.size() == 2) { String userid; // multiple username fields, split userid|username on | int pipeIndex = userName.indexOf('|'); if (pipeIndex < 0) { LOGGER.debug("Multiple user fields detected, please use userid|username as user name in client, except when userid is username"); userid = userName; } else { userid = userName.substring(0, pipeIndex); userName = userName.substring(pipeIndex + 1); // adjust credentials DavGatewayHttpClientFacade.setCredentials(httpClient, userName, password); } ((PostMethod) logonMethod).removeParameter("userid"); ((PostMethod) logonMethod).addParameter("userid", userid); userNameInput = "username"; } else if (userNameInputs.size() == 1) { // simple username field userNameInput = userNameInputs.get(0); } else { // should not happen userNameInput = "username"; } // make sure username and password fields are empty ((PostMethod) logonMethod).removeParameter(userNameInput); if (passwordInput != null) { ((PostMethod) logonMethod).removeParameter(passwordInput); } ((PostMethod) logonMethod).removeParameter("trusted"); ((PostMethod) logonMethod).removeParameter("flags"); if (passwordInput == null) { // This is a OTP pre-auth page. A different username may be required. otpPreAuthFound = true; otpPreAuthRetries++; ((PostMethod) logonMethod).addParameter(userNameInput, preAuthUsername); } else { otpPreAuthFound = false; otpPreAuthRetries = 0; // This is a regular Exchange login page ((PostMethod) logonMethod).addParameter(userNameInput, userName); ((PostMethod) logonMethod).addParameter(passwordInput, password); ((PostMethod) logonMethod).addParameter("trusted", "4"); ((PostMethod) logonMethod).addParameter("flags", "4"); } } protected HttpMethod formLogin(HttpClient httpClient, HttpMethod initmethod, String userName, String password) throws IOException { LOGGER.debug("Form based authentication detected"); HttpMethod logonMethod = buildLogonMethod(httpClient, initmethod); if (logonMethod == null) { LOGGER.debug("Authentication form not found at " + initmethod.getURI() + ", trying default url"); logonMethod = new PostMethod("/owa/auth/owaauth.dll"); } logonMethod = postLogonMethod(httpClient, logonMethod, userName, password); return logonMethod; } protected HttpMethod submitLanguageSelectionForm(HttpMethod logonMethod) throws IOException { PostMethod postLanguageFormMethod; // create an instance of HtmlCleaner HtmlCleaner cleaner = new HtmlCleaner(); try { TagNode node = cleaner.clean(logonMethod.getResponseBodyAsStream()); List forms = node.getElementListByName("form", true); TagNode languageForm; // select form if (forms.size() == 1) { languageForm = (TagNode) forms.get(0); } else { throw new IOException("Form not found"); } String languageMethodPath = languageForm.getAttributeByName("action"); postLanguageFormMethod = new PostMethod(getAbsoluteUri(logonMethod, languageMethodPath)); List inputList = languageForm.getElementListByName("input", true); for (Object input : inputList) { String name = ((TagNode) input).getAttributeByName("name"); String value = ((TagNode) input).getAttributeByName("value"); if (name != null && value != null) { postLanguageFormMethod.addParameter(name, value); } } List selectList = languageForm.getElementListByName("select", true); for (Object select : selectList) { String name = ((TagNode) select).getAttributeByName("name"); List optionList = ((TagNode) select).getElementListByName("option", true); String value = null; for (Object option : optionList) { if (((TagNode) option).getAttributeByName("selected") != null) { value = ((TagNode) option).getAttributeByName("value"); break; } } if (name != null && value != null) { postLanguageFormMethod.addParameter(name, value); } } } catch (IOException e) { String errorMessage = "Error parsing language selection form at " + logonMethod.getURI(); LOGGER.error(errorMessage); throw new IOException(errorMessage); } finally { logonMethod.releaseConnection(); } return DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, postLanguageFormMethod); } /** * Look for session cookies. * * @return true if session cookies are available */ protected boolean isAuthenticated(HttpMethod method) { boolean authenticated = false; if (method.getStatusCode() == HttpStatus.SC_OK && "/ews/services.wsdl".equalsIgnoreCase(method.getPath())) { // direct EWS access returned wsdl authenticated = true; } else { // check cookies for (Cookie cookie : httpClient.getState().getCookies()) { // Exchange 2003 cookies if (cookie.getName().startsWith("cadata") || "sessionid".equals(cookie.getName()) // Exchange 2007 cookie || "UserContext".equals(cookie.getName()) // Direct EWS access || "exchangecookie".equals(cookie.getName()) ) { authenticated = true; break; } } } return authenticated; } protected void checkFormLoginQueryString(HttpMethod logonMethod) throws DavMailAuthenticationException { String queryString = logonMethod.getQueryString(); if (queryString != null && (queryString.contains("reason=2") || queryString.contains("reason=4"))) { logonMethod.releaseConnection(); throwAuthenticationFailed(); } } protected void throwAuthenticationFailed() throws DavMailAuthenticationException { if (this.userName != null && this.userName.contains("\\")) { throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED"); } else { throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_RETRY"); } } protected abstract void buildSessionInfo(HttpMethod method) throws DavMailException; /** * Create message in specified folder. * Will overwrite an existing message with same subject in the same folder * * @param folderPath Exchange folder path * @param messageName message name * @param properties message properties (flags) * @param mimeMessage MIME message * @throws IOException when unable to create message */ public abstract void createMessage(String folderPath, String messageName, HashMap properties, MimeMessage mimeMessage) throws IOException; /** * Update given properties on message. * * @param message Exchange message * @param properties Webdav properties map * @throws IOException on error */ public abstract void updateMessage(Message message, Map properties) throws IOException; /** * Delete Exchange message. * * @param message Exchange message * @throws IOException on error */ public abstract void deleteMessage(Message message) throws IOException; /** * Get raw MIME message content * * @param message Exchange message * @return message body * @throws IOException on error */ protected abstract byte[] getContent(Message message) throws IOException; protected static final Set POP_MESSAGE_ATTRIBUTES = new HashSet(); static { POP_MESSAGE_ATTRIBUTES.add("uid"); POP_MESSAGE_ATTRIBUTES.add("imapUid"); POP_MESSAGE_ATTRIBUTES.add("messageSize"); } /** * Return folder message list with id and size only (for POP3 listener). * * @param folderName Exchange folder name * @return folder message list * @throws IOException on error */ public MessageList getAllMessageUidAndSize(String folderName) throws IOException { return searchMessages(folderName, POP_MESSAGE_ATTRIBUTES, null); } protected static final Set IMAP_MESSAGE_ATTRIBUTES = new HashSet(); static { IMAP_MESSAGE_ATTRIBUTES.add("permanenturl"); IMAP_MESSAGE_ATTRIBUTES.add("urlcompname"); IMAP_MESSAGE_ATTRIBUTES.add("uid"); IMAP_MESSAGE_ATTRIBUTES.add("messageSize"); IMAP_MESSAGE_ATTRIBUTES.add("imapUid"); IMAP_MESSAGE_ATTRIBUTES.add("junk"); IMAP_MESSAGE_ATTRIBUTES.add("flagStatus"); IMAP_MESSAGE_ATTRIBUTES.add("messageFlags"); IMAP_MESSAGE_ATTRIBUTES.add("lastVerbExecuted"); IMAP_MESSAGE_ATTRIBUTES.add("read"); IMAP_MESSAGE_ATTRIBUTES.add("deleted"); IMAP_MESSAGE_ATTRIBUTES.add("date"); IMAP_MESSAGE_ATTRIBUTES.add("lastmodified"); // OSX IMAP requests content-class IMAP_MESSAGE_ATTRIBUTES.add("contentclass"); IMAP_MESSAGE_ATTRIBUTES.add("keywords"); } protected static final Set UID_MESSAGE_ATTRIBUTES = new HashSet(); static { UID_MESSAGE_ATTRIBUTES.add("uid"); } /** * Get all folder messages. * * @param folderPath Exchange folder name * @return message list * @throws IOException on error */ public MessageList searchMessages(String folderPath) throws IOException { return searchMessages(folderPath, IMAP_MESSAGE_ATTRIBUTES, null); } /** * Search folder for messages matching conditions, with attributes needed by IMAP listener. * * @param folderName Exchange folder name * @param condition search filter * @return message list * @throws IOException on error */ public MessageList searchMessages(String folderName, Condition condition) throws IOException { return searchMessages(folderName, IMAP_MESSAGE_ATTRIBUTES, condition); } /** * Search folder for messages matching conditions, with given attributes. * * @param folderName Exchange folder name * @param attributes requested Webdav attributes * @param condition search filter * @return message list * @throws IOException on error */ public abstract MessageList searchMessages(String folderName, Set attributes, Condition condition) throws IOException; /** * Get server version (Exchange2003, Exchange2007 or Exchange2010) * * @return server version */ public String getServerVersion() { return serverVersion; } @SuppressWarnings({"JavaDoc"}) public enum Operator { Or, And, Not, IsEqualTo, IsGreaterThan, IsGreaterThanOrEqualTo, IsLessThan, IsLessThanOrEqualTo, IsNull, IsTrue, IsFalse, Like, StartsWith, Contains } /** * Exchange search filter. */ public static interface Condition { /** * Append condition to buffer. * * @param buffer search filter buffer */ void appendTo(StringBuilder buffer); /** * True if condition is empty. * * @return true if condition is empty */ boolean isEmpty(); /** * Test if the contact matches current condition. * * @param contact Exchange Contact * @return true if contact matches condition */ boolean isMatch(ExchangeSession.Contact contact); } /** * Attribute condition. */ public abstract static class AttributeCondition implements Condition { protected final String attributeName; protected final Operator operator; protected final String value; protected AttributeCondition(String attributeName, Operator operator, String value) { this.attributeName = attributeName; this.operator = operator; this.value = value; } public boolean isEmpty() { return false; } /** * Get attribute name. * * @return attribute name */ public String getAttributeName() { return attributeName; } /** * Condition value. * * @return value */ public String getValue() { return value; } } /** * Multiple condition. */ public abstract static class MultiCondition implements Condition { protected final Operator operator; protected final List conditions; protected MultiCondition(Operator operator, Condition... conditions) { this.operator = operator; this.conditions = new ArrayList(); for (Condition condition : conditions) { if (condition != null) { this.conditions.add(condition); } } } /** * Conditions list. * * @return conditions */ public List getConditions() { return conditions; } /** * Condition operator. * * @return operator */ public Operator getOperator() { return operator; } /** * Add a new condition. * * @param condition single condition */ public void add(Condition condition) { if (condition != null) { conditions.add(condition); } } public boolean isEmpty() { boolean isEmpty = true; for (Condition condition : conditions) { if (!condition.isEmpty()) { isEmpty = false; break; } } return isEmpty; } public boolean isMatch(ExchangeSession.Contact contact) { if (operator == Operator.And) { for (Condition condition : conditions) { if (!condition.isMatch(contact)) { return false; } } return true; } else if (operator == Operator.Or) { for (Condition condition : conditions) { if (condition.isMatch(contact)) { return true; } } return false; } else { return false; } } } /** * Not condition. */ public abstract static class NotCondition implements Condition { protected final Condition condition; protected NotCondition(Condition condition) { this.condition = condition; } public boolean isEmpty() { return condition.isEmpty(); } public boolean isMatch(ExchangeSession.Contact contact) { return !condition.isMatch(contact); } } /** * Single search filter condition. */ public abstract static class MonoCondition implements Condition { protected final String attributeName; protected final Operator operator; protected MonoCondition(String attributeName, Operator operator) { this.attributeName = attributeName; this.operator = operator; } public boolean isEmpty() { return false; } public boolean isMatch(ExchangeSession.Contact contact) { String actualValue = contact.get(attributeName); return (operator == Operator.IsNull && actualValue == null) || (operator == Operator.IsFalse && "false".equals(actualValue)) || (operator == Operator.IsTrue && "true".equals(actualValue)); } } /** * And search filter. * * @param condition search conditions * @return condition */ public abstract MultiCondition and(Condition... condition); /** * Or search filter. * * @param condition search conditions * @return condition */ public abstract MultiCondition or(Condition... condition); /** * Not search filter. * * @param condition search condition * @return condition */ public abstract Condition not(Condition condition); /** * Equals condition. * * @param attributeName logical Exchange attribute name * @param value attribute value * @return condition */ public abstract Condition isEqualTo(String attributeName, String value); /** * Equals condition. * * @param attributeName logical Exchange attribute name * @param value attribute value * @return condition */ public abstract Condition isEqualTo(String attributeName, int value); /** * MIME header equals condition. * * @param headerName MIME header name * @param value attribute value * @return condition */ public abstract Condition headerIsEqualTo(String headerName, String value); /** * Greater than or equals condition. * * @param attributeName logical Exchange attribute name * @param value attribute value * @return condition */ public abstract Condition gte(String attributeName, String value); /** * Greater than condition. * * @param attributeName logical Exchange attribute name * @param value attribute value * @return condition */ public abstract Condition gt(String attributeName, String value); /** * Lower than condition. * * @param attributeName logical Exchange attribute name * @param value attribute value * @return condition */ public abstract Condition lt(String attributeName, String value); /** * Lower than or equals condition. * * @param attributeName logical Exchange attribute name * @param value attribute value * @return condition */ @SuppressWarnings({"UnusedDeclaration"}) public abstract Condition lte(String attributeName, String value); /** * Contains condition. * * @param attributeName logical Exchange attribute name * @param value attribute value * @return condition */ public abstract Condition contains(String attributeName, String value); /** * Starts with condition. * * @param attributeName logical Exchange attribute name * @param value attribute value * @return condition */ public abstract Condition startsWith(String attributeName, String value); /** * Is null condition. * * @param attributeName logical Exchange attribute name * @return condition */ public abstract Condition isNull(String attributeName); /** * Is true condition. * * @param attributeName logical Exchange attribute name * @return condition */ public abstract Condition isTrue(String attributeName); /** * Is false condition. * * @param attributeName logical Exchange attribute name * @return condition */ public abstract Condition isFalse(String attributeName); /** * Search mail and generic folders under given folder. * Exclude calendar and contacts folders * * @param folderName Exchange folder name * @param recursive deep search if true * @return list of folders * @throws IOException on error */ public List getSubFolders(String folderName, boolean recursive) throws IOException { Condition folderCondition = null; if (!Settings.getBooleanProperty("davmail.imapIncludeSpecialFolders", false)) { folderCondition = or(isEqualTo("folderclass", "IPF.Note"), isNull("folderclass")); } List results = getSubFolders(folderName, folderCondition, recursive); // need to include base folder in recursive search, except on root if (recursive && folderName.length() > 0) { results.add(getFolder(folderName)); } return results; } /** * Search calendar folders under given folder. * * @param folderName Exchange folder name * @param recursive deep search if true * @return list of folders * @throws IOException on error */ public List getSubCalendarFolders(String folderName, boolean recursive) throws IOException { return getSubFolders(folderName, isEqualTo("folderclass", "IPF.Appointment"), recursive); } /** * Search folders under given folder matching filter. * * @param folderName Exchange folder name * @param condition search filter * @param recursive deep search if true * @return list of folders * @throws IOException on error */ public abstract List getSubFolders(String folderName, Condition condition, boolean recursive) throws IOException; /** * Delete oldest messages in trash. * keepDelay is the number of days to keep messages in trash before delete * * @throws IOException when unable to purge messages */ public void purgeOldestTrashAndSentMessages() throws IOException { int keepDelay = Settings.getIntProperty("davmail.keepDelay"); if (keepDelay != 0) { purgeOldestFolderMessages(TRASH, keepDelay); } // this is a new feature, default is : do nothing int sentKeepDelay = Settings.getIntProperty("davmail.sentKeepDelay"); if (sentKeepDelay != 0) { purgeOldestFolderMessages(SENT, sentKeepDelay); } } protected void purgeOldestFolderMessages(String folderPath, int keepDelay) throws IOException { Calendar cal = Calendar.getInstance(); cal.add(Calendar.DAY_OF_MONTH, -keepDelay); LOGGER.debug("Delete messages in " + folderPath + " not modified since " + cal.getTime()); MessageList messages = searchMessages(folderPath, UID_MESSAGE_ATTRIBUTES, lt("lastmodified", formatSearchDate(cal.getTime()))); for (Message message : messages) { message.delete(); } } protected void convertResentHeader(MimeMessage mimeMessage, String headerName) throws MessagingException { String[] resentHeader = mimeMessage.getHeader("Resent-" + headerName); if (resentHeader != null) { mimeMessage.removeHeader("Resent-" + headerName); mimeMessage.removeHeader(headerName); for (String value : resentHeader) { mimeMessage.addHeader(headerName, value); } } } protected String lastSentMessageId; /** * Send message in reader to recipients. * Detect visible recipients in message body to determine bcc recipients * * @param rcptToRecipients recipients list * @param mimeMessage mime message * @throws IOException on error * @throws MessagingException on error */ public void sendMessage(List rcptToRecipients, MimeMessage mimeMessage) throws IOException, MessagingException { // detect duplicate send command String messageId = mimeMessage.getMessageID(); if (lastSentMessageId != null && lastSentMessageId.equals(messageId)) { LOGGER.debug("Dropping message id " + messageId + ": already sent"); return; } lastSentMessageId = messageId; convertResentHeader(mimeMessage, "From"); convertResentHeader(mimeMessage, "To"); convertResentHeader(mimeMessage, "Cc"); convertResentHeader(mimeMessage, "Bcc"); convertResentHeader(mimeMessage, "Message-Id"); // do not allow send as another user on Exchange 2003 if ("Exchange2003".equals(serverVersion) || Settings.getBooleanProperty("davmail.smtpStripFrom", false)) { mimeMessage.removeHeader("From"); } // remove visible recipients from list Set visibleRecipients = new HashSet(); List recipients = getAllRecipients(mimeMessage); for (InternetAddress address : recipients) { visibleRecipients.add((address.getAddress().toLowerCase())); } for (String recipient : rcptToRecipients) { if (!visibleRecipients.contains(recipient.toLowerCase())) { mimeMessage.addRecipient(javax.mail.Message.RecipientType.BCC, new InternetAddress(recipient)); } } sendMessage(mimeMessage); } static final String[] RECIPIENT_HEADERS = {"to", "cc", "bcc"}; protected List getAllRecipients(MimeMessage mimeMessage) throws MessagingException { List recipientList = new ArrayList(); for (String recipientHeader : RECIPIENT_HEADERS) { final String recipientHeaderValue = mimeMessage.getHeader(recipientHeader, ","); if (recipientHeaderValue != null) { // parse headers in non strict mode recipientList.addAll(Arrays.asList(InternetAddress.parseHeader(recipientHeaderValue, false))); } } return recipientList; } /** * Send Mime message. * * @param mimeMessage MIME message * @throws IOException on error * @throws MessagingException on error */ public abstract void sendMessage(MimeMessage mimeMessage) throws IOException, MessagingException; /** * Get folder object. * Folder name can be logical names INBOX, Drafts, Trash or calendar, * or a path relative to user base folder or absolute path. * * @param folderPath folder path * @return Folder object * @throws IOException on error */ public ExchangeSession.Folder getFolder(String folderPath) throws IOException { Folder folder = internalGetFolder(folderPath); if (isMainCalendar(folderPath)) { Folder taskFolder = internalGetFolder(TASKS); folder.ctag += taskFolder.ctag; } return folder; } protected abstract Folder internalGetFolder(String folderName) throws IOException; /** * Check folder ctag and reload messages as needed. * * @param currentFolder current folder * @return true if folder changed * @throws IOException on error */ public boolean refreshFolder(Folder currentFolder) throws IOException { Folder newFolder = getFolder(currentFolder.folderPath); if (currentFolder.ctag == null || !currentFolder.ctag.equals(newFolder.ctag)) { if (LOGGER.isDebugEnabled()) { LOGGER.debug("Contenttag changed on " + currentFolder.folderPath + ' ' + currentFolder.ctag + " => " + newFolder.ctag + ", reloading messages"); } currentFolder.hasChildren = newFolder.hasChildren; currentFolder.noInferiors = newFolder.noInferiors; currentFolder.unreadCount = newFolder.unreadCount; currentFolder.ctag = newFolder.ctag; currentFolder.etag = newFolder.etag; if (newFolder.uidNext > currentFolder.uidNext) { currentFolder.uidNext = newFolder.uidNext; } currentFolder.loadMessages(); return true; } else { return false; } } /** * Create Exchange message folder. * * @param folderName logical folder name * @return status * @throws IOException on error */ public int createMessageFolder(String folderName) throws IOException { return createFolder(folderName, "IPF.Note", null); } /** * Create Exchange calendar folder. * * @param folderName logical folder name * @param properties folder properties * @return status * @throws IOException on error */ public int createCalendarFolder(String folderName, Map properties) throws IOException { return createFolder(folderName, "IPF.Appointment", properties); } /** * Create Exchange contact folder. * * @param folderName logical folder name * @param properties folder properties * @return status * @throws IOException on error */ public int createContactFolder(String folderName, Map properties) throws IOException { return createFolder(folderName, "IPF.Contact", properties); } /** * Create Exchange folder with given folder class. * * @param folderName logical folder name * @param folderClass folder class * @param properties folder properties * @return status * @throws IOException on error */ public abstract int createFolder(String folderName, String folderClass, Map properties) throws IOException; /** * Update Exchange folder properties. * * @param folderName logical folder name * @param properties folder properties * @return status * @throws IOException on error */ public abstract int updateFolder(String folderName, Map properties) throws IOException; /** * Delete Exchange folder. * * @param folderName logical folder name * @throws IOException on error */ public abstract void deleteFolder(String folderName) throws IOException; /** * Copy message to target folder * * @param message Exchange message * @param targetFolder target folder * @throws IOException on error */ public abstract void copyMessage(Message message, String targetFolder) throws IOException; /** * Move message to target folder * * @param message Exchange message * @param targetFolder target folder * @throws IOException on error */ public abstract void moveMessage(Message message, String targetFolder) throws IOException; /** * Move folder to target name. * * @param folderName current folder name/path * @param targetName target folder name/path * @throws IOException on error */ public abstract void moveFolder(String folderName, String targetName) throws IOException; /** * Move item from source path to target path. * * @param sourcePath item source path * @param targetPath item target path * @throws IOException on error */ public abstract void moveItem(String sourcePath, String targetPath) throws IOException; protected abstract void moveToTrash(Message message) throws IOException; /** * Convert keyword value to IMAP flag. * * @param value keyword value * @return IMAP flag */ public String convertKeywordToFlag(String value) { // first test for keyword in settings Properties flagSettings = Settings.getSubProperties("davmail.imapFlags"); Enumeration flagSettingsEnum = flagSettings.propertyNames(); while (flagSettingsEnum.hasMoreElements()) { String key = (String) flagSettingsEnum.nextElement(); if (value.equalsIgnoreCase(flagSettings.getProperty(key))) { return key; } } ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags"); Enumeration flagBundleEnum = flagBundle.getKeys(); while (flagBundleEnum.hasMoreElements()) { String key = flagBundleEnum.nextElement(); if (value.equalsIgnoreCase(flagBundle.getString(key))) { return key; } } // fall back to raw value return value; } /** * Convert IMAP flag to keyword value. * * @param value IMAP flag * @return keyword value */ public String convertFlagToKeyword(String value) { // first test for flag in settings Properties flagSettings = Settings.getSubProperties("davmail.imapFlags"); String flagValue = flagSettings.getProperty(value); if (flagValue != null) { return flagValue; } // fall back to predefined flags ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags"); try { return flagBundle.getString(value); } catch (MissingResourceException e) { // ignore } // fall back to raw value return value; } /** * Exchange folder with IMAP properties */ public class Folder { /** * Logical (IMAP) folder path. */ public String folderPath; /** * Display Name. */ public String displayName; /** * Folder class (PR_CONTAINER_CLASS). */ public String folderClass; /** * Folder message count. */ public int count; /** * Folder unread message count. */ public int unreadCount; /** * true if folder has subfolders (DAV:hassubs). */ public boolean hasChildren; /** * true if folder has no subfolders (DAV:nosubs). */ public boolean noInferiors; /** * Folder content tag (to detect folder content changes). */ public String ctag; /** * Folder etag (to detect folder object changes). */ public String etag; /** * Next IMAP uid */ public long uidNext; /** * recent count */ public int recent; /** * Folder message list, empty before loadMessages call. */ public ExchangeSession.MessageList messages; /** * Permanent uid (PR_SEARCH_KEY) to IMAP UID map. */ private final HashMap permanentUrlToImapUidMap = new HashMap(); /** * Get IMAP folder flags. * * @return folder flags in IMAP format */ public String getFlags() { if (noInferiors) { return "\\NoInferiors"; } else if (hasChildren) { return "\\HasChildren"; } else { return "\\HasNoChildren"; } } /** * Load folder messages. * * @throws IOException on error */ public void loadMessages() throws IOException { messages = ExchangeSession.this.searchMessages(folderPath, null); fixUids(messages); recent = 0; for (Message message : messages) { if (message.recent) { recent++; } } long computedUidNext = 1; if (!messages.isEmpty()) { computedUidNext = messages.get(messages.size() - 1).getImapUid() + 1; } if (computedUidNext > uidNext) { uidNext = computedUidNext; } } /** * Search messages in folder matching query. * * @param condition search query * @return message list * @throws IOException on error */ public MessageList searchMessages(Condition condition) throws IOException { MessageList localMessages = ExchangeSession.this.searchMessages(folderPath, condition); fixUids(localMessages); return localMessages; } /** * Restore previous uids changed by a PROPPATCH (flag change). * * @param messages message list */ protected void fixUids(MessageList messages) { boolean sortNeeded = false; for (Message message : messages) { if (permanentUrlToImapUidMap.containsKey(message.getPermanentId())) { long previousUid = permanentUrlToImapUidMap.get(message.getPermanentId()); if (message.getImapUid() != previousUid) { LOGGER.debug("Restoring IMAP uid " + message.getImapUid() + " -> " + previousUid + " for message " + message.getPermanentId()); message.setImapUid(previousUid); sortNeeded = true; } } else { // add message to uid map permanentUrlToImapUidMap.put(message.getPermanentId(), message.getImapUid()); } } if (sortNeeded) { Collections.sort(messages); } } /** * Folder message count. * * @return message count */ public int count() { if (messages == null) { return count; } else { return messages.size(); } } /** * Compute IMAP uidnext. * * @return max(messageuids)+1 */ public long getUidNext() { return uidNext; } /** * Get message at index. * * @param index message index * @return message */ public Message get(int index) { return messages.get(index); } /** * Get current folder messages imap uids and flags * * @return imap uid list */ public TreeMap getImapFlagMap() { TreeMap imapFlagMap = new TreeMap(); for (ExchangeSession.Message message : messages) { imapFlagMap.put(message.getImapUid(), message.getImapFlags()); } return imapFlagMap; } /** * Calendar folder flag. * * @return true if this is a calendar folder */ public boolean isCalendar() { return "IPF.Appointment".equals(folderClass); } /** * Contact folder flag. * * @return true if this is a calendar folder */ public boolean isContact() { return "IPF.Contact".equals(folderClass); } /** * Task folder flag. * * @return true if this is a task folder */ public boolean isTask() { return "IPF.Task".equals(folderClass); } /** * drop cached message */ public void clearCache() { messages.cachedMimeBody = null; messages.cachedMimeMessage = null; messages.cachedMessageImapUid = 0; } } /** * Exchange message. */ public abstract class Message implements Comparable { /** * enclosing message list */ public MessageList messageList; /** * Message url. */ public String messageUrl; /** * Message permanent url (does not change on message move). */ public String permanentUrl; /** * Message uid. */ public String uid; /** * Message content class. */ public String contentClass; /** * Message keywords (categories). */ public String keywords; /** * Message IMAP uid, unique in folder (x0e230003). */ public long imapUid; /** * MAPI message size. */ public int size; /** * Message date (urn:schemas:mailheader:date). */ public String date; /** * Message flag: read. */ public boolean read; /** * Message flag: deleted. */ public boolean deleted; /** * Message flag: junk. */ public boolean junk; /** * Message flag: flagged. */ public boolean flagged; /** * Message flag: recent. */ public boolean recent; /** * Message flag: draft. */ public boolean draft; /** * Message flag: answered. */ public boolean answered; /** * Message flag: fowarded. */ public boolean forwarded; /** * Unparsed message content. */ protected SharedByteArrayInputStream mimeBody; /** * Message content parsed in a MIME message. */ protected MimeMessage mimeMessage; /** * Get permanent message id. * permanentUrl over WebDav or IitemId over EWS * * @return permanent id */ public abstract String getPermanentId(); /** * IMAP uid , unique in folder (x0e230003) * * @return IMAP uid */ public long getImapUid() { return imapUid; } /** * Set IMAP uid. * * @param imapUid new uid */ public void setImapUid(long imapUid) { this.imapUid = imapUid; } /** * Exchange uid. * * @return uid */ public String getUid() { return uid; } /** * Return message flags in IMAP format. * * @return IMAP flags */ public String getImapFlags() { StringBuilder buffer = new StringBuilder(); if (read) { buffer.append("\\Seen "); } if (deleted) { buffer.append("\\Deleted "); } if (recent) { buffer.append("\\Recent "); } if (flagged) { buffer.append("\\Flagged "); } if (junk) { buffer.append("Junk "); } if (draft) { buffer.append("\\Draft "); } if (answered) { buffer.append("\\Answered "); } if (forwarded) { buffer.append("$Forwarded "); } if (keywords != null) { for (String keyword : keywords.split(",")) { buffer.append(convertKeywordToFlag(keyword)).append(" "); } } return buffer.toString().trim(); } /** * Load message content in a Mime message * * @throws IOException on error * @throws MessagingException on error */ public void loadMimeMessage() throws IOException, MessagingException { if (mimeMessage == null) { // try to get message content from cache if (this.imapUid == messageList.cachedMessageImapUid) { mimeBody = messageList.cachedMimeBody; mimeMessage = messageList.cachedMimeMessage; LOGGER.debug("Got message content for " + imapUid + " from cache"); } else { // load and parse message mimeBody = new SharedByteArrayInputStream(getContent(this)); mimeMessage = new MimeMessage(null, mimeBody); mimeBody.reset(); // workaround for Exchange 2003 ActiveSync bug if (mimeMessage.getHeader("MAIL FROM") != null) { mimeBody = (SharedByteArrayInputStream) mimeMessage.getRawInputStream(); mimeMessage = new MimeMessage(null, mimeBody); mimeBody.reset(); } LOGGER.debug("Downloaded full message content for IMAP UID " + imapUid + " (" + mimeBody.available() + " bytes)"); } } } /** * Get message content as a Mime message. * * @return mime message * @throws IOException on error * @throws MessagingException on error */ public MimeMessage getMimeMessage() throws IOException, MessagingException { loadMimeMessage(); return mimeMessage; } public Enumeration getMatchingHeaderLinesFromHeaders(String[] headerNames) throws MessagingException, IOException { Enumeration result = null; if (mimeMessage == null) { // message not loaded, try to get headers only InputStream headers = getMimeHeaders(); if (headers != null) { InternetHeaders internetHeaders = new InternetHeaders(headers); if (internetHeaders.getHeader("Subject") == null) { // invalid header content return null; } if (headerNames == null) { result = internetHeaders.getAllHeaderLines(); } else { result = internetHeaders.getMatchingHeaderLines(headerNames); } } } return result; } public Enumeration getMatchingHeaderLines(String[] headerNames) throws MessagingException, IOException { Enumeration result = getMatchingHeaderLinesFromHeaders(headerNames); if (result == null) { if (headerNames == null) { result = getMimeMessage().getAllHeaderLines(); } else { result = getMimeMessage().getMatchingHeaderLines(headerNames); } } return result; } protected abstract InputStream getMimeHeaders(); /** * Get message body size. * * @return mime message size * @throws IOException on error * @throws MessagingException on error */ public int getMimeMessageSize() throws IOException, MessagingException { loadMimeMessage(); mimeBody.reset(); return mimeBody.available(); } /** * Get message body input stream. * * @return mime message InputStream * @throws IOException on error * @throws MessagingException on error */ public InputStream getRawInputStream() throws IOException, MessagingException { loadMimeMessage(); mimeBody.reset(); return mimeBody; } /** * Drop mime message to avoid keeping message content in memory, * keep a single message in MessageList cache to handle chunked fetch. */ public void dropMimeMessage() { // update single message cache if (mimeMessage != null) { messageList.cachedMessageImapUid = imapUid; messageList.cachedMimeBody = mimeBody; messageList.cachedMimeMessage = mimeMessage; } // drop curent message body to save memory mimeMessage = null; mimeBody = null; } public boolean isLoaded() { return mimeMessage != null; } /** * Delete message. * * @throws IOException on error */ public void delete() throws IOException { deleteMessage(this); } /** * Move message to trash, mark message read. * * @throws IOException on error */ public void moveToTrash() throws IOException { markRead(); ExchangeSession.this.moveToTrash(this); } /** * Mark message as read. * * @throws IOException on error */ public void markRead() throws IOException { HashMap properties = new HashMap(); properties.put("read", "1"); updateMessage(this, properties); } /** * Comparator to sort messages by IMAP uid * * @param message other message * @return imapUid comparison result */ public int compareTo(Message message) { long compareValue = (imapUid - message.imapUid); if (compareValue > 0) { return 1; } else if (compareValue < 0) { return -1; } else { return 0; } } /** * Override equals, compare IMAP uids * * @param message other message * @return true if IMAP uids are equal */ @Override public boolean equals(Object message) { return message instanceof Message && imapUid == ((Message) message).imapUid; } /** * Override hashCode, return imapUid hashcode. * * @return imapUid hashcode */ @Override public int hashCode() { return (int) (imapUid ^ (imapUid >>> 32)); } public String removeFlag(String flag) { if (keywords != null) { final String exchangeFlag = convertFlagToKeyword(flag); Set keywordSet = new HashSet(); String[] keywordArray = keywords.split(","); for (String value : keywordArray) { if (!value.equalsIgnoreCase(exchangeFlag)) { keywordSet.add(value); } } keywords = StringUtil.join(keywordSet, ","); } return keywords; } public String addFlag(String flag) { final String exchangeFlag = convertFlagToKeyword(flag); HashSet keywordSet = new HashSet(); boolean hasFlag = false; if (keywords != null) { String[] keywordArray = keywords.split(","); for (String value : keywordArray) { keywordSet.add(value); if (value.equalsIgnoreCase(exchangeFlag)) { hasFlag = true; } } } if (!hasFlag) { keywordSet.add(exchangeFlag); } keywords = StringUtil.join(keywordSet, ","); return keywords; } public String setFlags(HashSet flags) { HashSet keywordSet = new HashSet(); for (String flag : flags) { keywordSet.add(convertFlagToKeyword(flag)); } keywords = StringUtil.join(keywordSet, ","); return keywords; } } /** * Message list, includes a single messsage cache */ public static class MessageList extends ArrayList { /** * Cached message content parsed in a MIME message. */ protected transient MimeMessage cachedMimeMessage; /** * Cached message uid. */ protected transient long cachedMessageImapUid; /** * Cached unparsed message */ protected transient SharedByteArrayInputStream cachedMimeBody; } /** * Generic folder item. */ public abstract static class Item extends HashMap { protected String folderPath; protected String itemName; protected String permanentUrl; /** * Display name. */ public String displayName; /** * item etag */ public String etag; protected String noneMatch; /** * Build item instance. * * @param folderPath folder path * @param itemName item name class * @param etag item etag * @param noneMatch none match flag */ public Item(String folderPath, String itemName, String etag, String noneMatch) { this.folderPath = folderPath; this.itemName = itemName; this.etag = etag; this.noneMatch = noneMatch; } /** * Default constructor. */ protected Item() { } /** * Return item content type * * @return content type */ public abstract String getContentType(); /** * Retrieve item body from Exchange * * @return item body * @throws HttpException on error */ public abstract String getBody() throws IOException; /** * Get event name (file name part in URL). * * @return event name */ public String getName() { return itemName; } /** * Get event etag (last change tag). * * @return event etag */ public String getEtag() { return etag; } /** * Set item href. * * @param href item href */ public void setHref(String href) { int index = href.lastIndexOf('/'); if (index >= 0) { folderPath = href.substring(0, index); itemName = href.substring(index + 1); } else { throw new IllegalArgumentException(href); } } /** * Return item href. * * @return item href */ public String getHref() { return folderPath + '/' + itemName; } } /** * Contact object */ public abstract class Contact extends Item { /** * @inheritDoc */ public Contact(String folderPath, String itemName, Map properties, String etag, String noneMatch) { super(folderPath, itemName.endsWith(".vcf") ? itemName.substring(0, itemName.length() - 3) + "EML" : itemName, etag, noneMatch); this.putAll(properties); } /** * @inheritDoc */ protected Contact() { } /** * Convert EML extension to vcf. * * @return item name */ @Override public String getName() { String name = super.getName(); if (name.endsWith(".EML")) { name = name.substring(0, name.length() - 3) + "vcf"; } return name; } /** * Set contact name * * @param name contact name */ public void setName(String name) { this.itemName = name; } /** * Compute vcard uid from name. * * @return uid * @throws URIException on error */ protected String getUid() throws URIException { String uid = getName(); int dotIndex = uid.lastIndexOf('.'); if (dotIndex > 0) { uid = uid.substring(0, dotIndex); } return URIUtil.encodePath(uid); } @Override public String getContentType() { return "text/vcard"; } @Override public String getBody() throws HttpException { // build RFC 2426 VCard from contact information VCardWriter writer = new VCardWriter(); writer.startCard(); writer.appendProperty("UID", getUid()); // common name writer.appendProperty("FN", get("cn")); // RFC 2426: Family Name, Given Name, Additional Names, Honorific Prefixes, and Honorific Suffixes writer.appendProperty("N", get("sn"), get("givenName"), get("middlename"), get("personaltitle"), get("namesuffix")); writer.appendProperty("TEL;TYPE=cell", get("mobile")); writer.appendProperty("TEL;TYPE=work", get("telephoneNumber")); writer.appendProperty("TEL;TYPE=home", get("homePhone")); writer.appendProperty("TEL;TYPE=fax", get("facsimiletelephonenumber")); writer.appendProperty("TEL;TYPE=pager", get("pager")); writer.appendProperty("TEL;TYPE=car", get("othermobile")); writer.appendProperty("TEL;TYPE=home,fax", get("homefax")); writer.appendProperty("TEL;TYPE=isdn", get("internationalisdnnumber")); writer.appendProperty("TEL;TYPE=msg", get("otherTelephone")); // The structured type value corresponds, in sequence, to the post office box; the extended address; // the street address; the locality (e.g., city); the region (e.g., state or province); // the postal code; the country name writer.appendProperty("ADR;TYPE=home", get("homepostofficebox"), null, get("homeStreet"), get("homeCity"), get("homeState"), get("homePostalCode"), get("homeCountry")); writer.appendProperty("ADR;TYPE=work", get("postofficebox"), get("roomnumber"), get("street"), get("l"), get("st"), get("postalcode"), get("co")); writer.appendProperty("ADR;TYPE=other", get("otherpostofficebox"), null, get("otherstreet"), get("othercity"), get("otherstate"), get("otherpostalcode"), get("othercountry")); writer.appendProperty("EMAIL;TYPE=work", get("smtpemail1")); writer.appendProperty("EMAIL;TYPE=home", get("smtpemail2")); writer.appendProperty("EMAIL;TYPE=other", get("smtpemail3")); writer.appendProperty("ORG", get("o"), get("department")); writer.appendProperty("URL;TYPE=work", get("businesshomepage")); writer.appendProperty("URL;TYPE=home", get("personalHomePage")); writer.appendProperty("TITLE", get("title")); writer.appendProperty("NOTE", get("description")); writer.appendProperty("CUSTOM1", get("extensionattribute1")); writer.appendProperty("CUSTOM2", get("extensionattribute2")); writer.appendProperty("CUSTOM3", get("extensionattribute3")); writer.appendProperty("CUSTOM4", get("extensionattribute4")); writer.appendProperty("ROLE", get("profession")); writer.appendProperty("NICKNAME", get("nickname")); writer.appendProperty("X-AIM", get("im")); writer.appendProperty("BDAY", convertZuluDateToBday(get("bday"))); writer.appendProperty("ANNIVERSARY", convertZuluDateToBday(get("anniversary"))); String gender = get("gender"); if ("1".equals(gender)) { writer.appendProperty("SEX", "2"); } else if ("2".equals(gender)) { writer.appendProperty("SEX", "1"); } writer.appendProperty("CATEGORIES", get("keywords")); writer.appendProperty("FBURL", get("fburl")); if ("1".equals(get("private"))) { writer.appendProperty("CLASS", "PRIVATE"); } writer.appendProperty("X-ASSISTANT", get("secretarycn")); writer.appendProperty("X-MANAGER", get("manager")); writer.appendProperty("X-SPOUSE", get("spousecn")); writer.appendProperty("REV", get("lastmodified")); if ("true".equals(get("haspicture"))) { try { ContactPhoto contactPhoto = getContactPhoto(this); writer.writeLine("PHOTO;BASE64;TYPE=\"" + contactPhoto.contentType + "\";ENCODING=\"b\":"); writer.writeLine(contactPhoto.content, true); } catch (IOException e) { LOGGER.warn("Unable to get photo from contact " + this.get("cn")); } } writer.endCard(); return writer.toString(); } } /** * Calendar event object. */ public abstract class Event extends Item { protected String contentClass; protected String subject; protected VCalendar vCalendar; /** * @inheritDoc */ public Event(String folderPath, String itemName, String contentClass, String itemBody, String etag, String noneMatch) throws IOException { super(folderPath, itemName, etag, noneMatch); this.contentClass = contentClass; fixICS(itemBody.getBytes("UTF-8"), false); // fix task item name if (vCalendar.isTodo() && this.itemName.endsWith(".ics")) { this.itemName = itemName.substring(0, itemName.length() - 3) + "EML"; } } /** * @inheritDoc */ protected Event() { } @Override public String getContentType() { return "text/calendar;charset=UTF-8"; } @Override public String getBody() throws IOException { if (vCalendar == null) { fixICS(getEventContent(), true); } return vCalendar.toString(); } protected HttpException buildHttpException(Exception e) { String message = "Unable to get event " + getName() + " subject: " + subject + " at " + permanentUrl + ": " + e.getMessage(); LOGGER.warn(message); return new HttpException(message); } /** * Retrieve item body from Exchange * * @return item content * @throws HttpException on error */ public abstract byte[] getEventContent() throws IOException; protected static final String TEXT_CALENDAR = "text/calendar"; protected static final String APPLICATION_ICS = "application/ics"; protected boolean isCalendarContentType(String contentType) { return TEXT_CALENDAR.regionMatches(true, 0, contentType, 0, TEXT_CALENDAR.length()) || APPLICATION_ICS.regionMatches(true, 0, contentType, 0, APPLICATION_ICS.length()); } protected MimePart getCalendarMimePart(MimeMultipart multiPart) throws IOException, MessagingException { MimePart bodyPart = null; for (int i = 0; i < multiPart.getCount(); i++) { String contentType = multiPart.getBodyPart(i).getContentType(); if (isCalendarContentType(contentType)) { bodyPart = (MimePart) multiPart.getBodyPart(i); break; } else if (contentType.startsWith("multipart")) { Object content = multiPart.getBodyPart(i).getContent(); if (content instanceof MimeMultipart) { bodyPart = getCalendarMimePart((MimeMultipart) content); } } } return bodyPart; } /** * Load ICS content from MIME message input stream * * @param mimeInputStream mime message input stream * @return mime message ics attachment body * @throws IOException on error * @throws MessagingException on error */ protected byte[] getICS(InputStream mimeInputStream) throws IOException, MessagingException { byte[] result; MimeMessage mimeMessage = new MimeMessage(null, mimeInputStream); String[] contentClassHeader = mimeMessage.getHeader("Content-class"); // task item, return null if (contentClassHeader != null && contentClassHeader.length > 0 && "urn:content-classes:task".equals(contentClassHeader[0])) { return null; } Object mimeBody = mimeMessage.getContent(); MimePart bodyPart = null; if (mimeBody instanceof MimeMultipart) { bodyPart = getCalendarMimePart((MimeMultipart) mimeBody); } else if (isCalendarContentType(mimeMessage.getContentType())) { // no multipart, single body bodyPart = mimeMessage; } if (bodyPart != null) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); bodyPart.getDataHandler().writeTo(baos); baos.close(); result = baos.toByteArray(); } else { ByteArrayOutputStream baos = new ByteArrayOutputStream(); mimeMessage.writeTo(baos); baos.close(); throw new DavMailException("EXCEPTION_INVALID_MESSAGE_CONTENT", new String(baos.toByteArray(), "UTF-8")); } return result; } protected void fixICS(byte[] icsContent, boolean fromServer) throws IOException { if (LOGGER.isDebugEnabled() && fromServer) { dumpIndex++; String icsBody = new String(icsContent, "UTF-8"); dumpICS(icsBody, fromServer, false); LOGGER.debug("Vcalendar body received from server:\n" + icsBody); } vCalendar = new VCalendar(icsContent, getEmail(), getVTimezone()); vCalendar.fixVCalendar(fromServer); if (LOGGER.isDebugEnabled() && !fromServer) { String resultString = vCalendar.toString(); LOGGER.debug("Fixed Vcalendar body to server:\n" + resultString); dumpICS(resultString, fromServer, true); } } protected void dumpICS(String icsBody, boolean fromServer, boolean after) { String logFileDirectory = Settings.getLogFileDirectory(); // additional setting to activate ICS dump (not available in GUI) int dumpMax = Settings.getIntProperty("davmail.dumpICS"); if (dumpMax > 0) { if (dumpIndex > dumpMax) { // Delete the oldest dump file final int oldest = dumpIndex - dumpMax; try { File[] oldestFiles = (new File(logFileDirectory)).listFiles(new FilenameFilter() { public boolean accept(File dir, String name) { if (name.endsWith(".ics")) { int dashIndex = name.indexOf('-'); if (dashIndex > 0) { try { int fileIndex = Integer.parseInt(name.substring(0, dashIndex)); return fileIndex < oldest; } catch (NumberFormatException nfe) { // ignore } } } return false; } }); for (File file : oldestFiles) { if (!file.delete()) { LOGGER.warn("Unable to delete " + file.getAbsolutePath()); } } } catch (Exception ex) { LOGGER.warn("Error deleting ics dump: " + ex.getMessage()); } } StringBuilder filePath = new StringBuilder(); filePath.append(logFileDirectory).append('/') .append(dumpIndex) .append(after ? "-to" : "-from") .append((after ^ fromServer) ? "-server" : "-client") .append(".ics"); if ((icsBody != null) && (icsBody.length() > 0)) { FileWriter fileWriter = null; try { fileWriter = new FileWriter(filePath.toString()); fileWriter.write(icsBody); } catch (IOException e) { LOGGER.error(e); } finally { if (fileWriter != null) { try { fileWriter.close(); } catch (IOException e) { LOGGER.error(e); } } } } } } /** * Build Mime body for event or event message. * * @return mimeContent as byte array or null * @throws IOException on error */ public byte[] createMimeContent() throws IOException { String boundary = UUID.randomUUID().toString(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); MimeOutputStreamWriter writer = new MimeOutputStreamWriter(baos); writer.writeHeader("Content-Transfer-Encoding", "7bit"); writer.writeHeader("Content-class", contentClass); // append date writer.writeHeader("Date", new Date()); // Make sure invites have a proper subject line String vEventSubject = vCalendar.getFirstVeventPropertyValue("SUMMARY"); if (vEventSubject == null) { vEventSubject = BundleMessage.format("MEETING_REQUEST"); } // Write a part of the message that contains the // ICS description so that invites contain the description text String description = vCalendar.getFirstVeventPropertyValue("DESCRIPTION"); // handle notifications if ("urn:content-classes:calendarmessage".equals(contentClass)) { // need to parse attendees and organizer to build recipients VCalendar.Recipients recipients = vCalendar.getRecipients(true); String to; String cc; String notificationSubject; if (email.equalsIgnoreCase(recipients.organizer)) { // current user is organizer => notify all to = recipients.attendees; cc = recipients.optionalAttendees; notificationSubject = subject; } else { String status = vCalendar.getAttendeeStatus(); // notify only organizer to = recipients.organizer; cc = null; notificationSubject = (status != null) ? (BundleMessage.format(status) + vEventSubject) : subject; description = ""; } // Allow end user notification edit if (Settings.getBooleanProperty("davmail.caldavEditNotifications")) { // create notification edit dialog NotificationDialog notificationDialog = new NotificationDialog(to, cc, notificationSubject, description); if (!notificationDialog.getSendNotification()) { LOGGER.debug("Notification canceled by user"); return null; } // get description from dialog to = notificationDialog.getTo(); cc = notificationDialog.getCc(); notificationSubject = notificationDialog.getSubject(); description = notificationDialog.getBody(); } // do not send notification if no recipients found if ((to == null || to.length() == 0) && (cc == null || cc.length() == 0)) { return null; } writer.writeHeader("To", to); writer.writeHeader("Cc", cc); writer.writeHeader("Subject", notificationSubject); if (LOGGER.isDebugEnabled()) { StringBuilder logBuffer = new StringBuilder("Sending notification "); if (to != null) { logBuffer.append("to: ").append(to); } if (cc != null) { logBuffer.append("cc: ").append(cc); } LOGGER.debug(logBuffer.toString()); } } else { // need to parse attendees and organizer to build recipients VCalendar.Recipients recipients = vCalendar.getRecipients(false); // storing appointment, full recipients header if (recipients.attendees != null) { writer.writeHeader("To", recipients.attendees); } else { // use current user as attendee writer.writeHeader("To", email); } writer.writeHeader("Cc", recipients.optionalAttendees); if (recipients.organizer != null) { writer.writeHeader("From", recipients.organizer); } else { writer.writeHeader("From", email); } } if (vCalendar.getMethod() == null) { vCalendar.setPropertyValue("METHOD", "REQUEST"); } writer.writeHeader("MIME-Version", "1.0"); writer.writeHeader("Content-Type", "multipart/alternative;\r\n" + "\tboundary=\"----=_NextPart_" + boundary + '\"'); writer.writeLn(); writer.writeLn("This is a multi-part message in MIME format."); writer.writeLn(); writer.writeLn("------=_NextPart_" + boundary); if (description != null && description.length() > 0) { writer.writeHeader("Content-Type", "text/plain;\r\n" + "\tcharset=\"utf-8\""); writer.writeHeader("content-transfer-encoding", "8bit"); writer.writeLn(); writer.flush(); baos.write(description.getBytes("UTF-8")); writer.writeLn(); writer.writeLn("------=_NextPart_" + boundary); } writer.writeHeader("Content-class", contentClass); writer.writeHeader("Content-Type", "text/calendar;\r\n" + "\tmethod=" + vCalendar.getMethod() + ";\r\n" + "\tcharset=\"utf-8\"" ); writer.writeHeader("Content-Transfer-Encoding", "8bit"); writer.writeLn(); writer.flush(); baos.write(vCalendar.toString().getBytes("UTF-8")); writer.writeLn(); writer.writeLn("------=_NextPart_" + boundary + "--"); writer.close(); return baos.toByteArray(); } /** * Create or update item * * @return action result * @throws IOException on error */ public abstract ItemResult createOrUpdate() throws IOException; } protected abstract Set getItemProperties(); /** * Search contacts in provided folder. * * @param folderPath Exchange folder path * @return list of contacts * @throws IOException on error */ public List getAllContacts(String folderPath) throws IOException { return searchContacts(folderPath, ExchangeSession.CONTACT_ATTRIBUTES, isEqualTo("outlookmessageclass", "IPM.Contact"), 0); } /** * Search contacts in provided folder matching the search query. * * @param folderPath Exchange folder path * @param attributes requested attributes * @param condition Exchange search query * @param maxCount maximum item count * @return list of contacts * @throws IOException on error */ public abstract List searchContacts(String folderPath, Set attributes, Condition condition, int maxCount) throws IOException; /** * Search calendar messages in provided folder. * * @param folderPath Exchange folder path * @return list of calendar messages as Event objects * @throws IOException on error */ public abstract List getEventMessages(String folderPath) throws IOException; /** * Search calendar events in provided folder. * * @param folderPath Exchange folder path * @return list of calendar events * @throws IOException on error */ public List getAllEvents(String folderPath) throws IOException { List results = searchEvents(folderPath, getCalendarItemCondition(getPastDelayCondition("dtstart"))); if (!Settings.getBooleanProperty("davmail.caldavDisableTasks", false) && isMainCalendar(folderPath)) { // retrieve tasks from main tasks folder results.addAll(searchTasksOnly(TASKS)); } return results; } protected abstract Condition getCalendarItemCondition(Condition dateCondition); protected Condition getPastDelayCondition(String attribute) { int caldavPastDelay = Settings.getIntProperty("davmail.caldavPastDelay"); Condition dateCondition = null; if (caldavPastDelay != 0) { Calendar cal = Calendar.getInstance(); cal.add(Calendar.DAY_OF_MONTH, -caldavPastDelay); dateCondition = gt(attribute, formatSearchDate(cal.getTime())); } return dateCondition; } protected Condition getRangeCondition(String timeRangeStart, String timeRangeEnd) throws IOException { try { SimpleDateFormat parser = getZuluDateFormat(); ExchangeSession.MultiCondition andCondition = and(); if (timeRangeStart != null) { andCondition.add(gt("dtend", formatSearchDate(parser.parse(timeRangeStart)))); } if (timeRangeEnd != null) { andCondition.add(lt("dtstart", formatSearchDate(parser.parse(timeRangeEnd)))); } return andCondition; } catch (ParseException e) { throw new IOException(e + " " + e.getMessage()); } } /** * Search events between start and end. * * @param folderPath Exchange folder path * @param timeRangeStart date range start in zulu format * @param timeRangeEnd date range start in zulu format * @return list of calendar events * @throws IOException on error */ public List searchEvents(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException { Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd); Condition condition = getCalendarItemCondition(dateCondition); return searchEvents(folderPath, condition); } /** * Search events between start and end, exclude tasks. * * @param folderPath Exchange folder path * @param timeRangeStart date range start in zulu format * @param timeRangeEnd date range start in zulu format * @return list of calendar events * @throws IOException on error */ public List searchEventsOnly(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException { Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd); return searchEvents(folderPath, getCalendarItemCondition(dateCondition)); } /** * Search tasks only (VTODO). * * @param folderPath Exchange folder path * @return list of tasks * @throws IOException on error */ public List searchTasksOnly(String folderPath) throws IOException { return searchEvents(folderPath, and(isEqualTo("outlookmessageclass", "IPM.Task"), or(isNull("datecompleted"), getPastDelayCondition("datecompleted")))); } /** * Search calendar events in provided folder. * * @param folderPath Exchange folder path * @param filter search filter * @return list of calendar events * @throws IOException on error */ public List searchEvents(String folderPath, Condition filter) throws IOException { Condition privateCondition = null; if (isSharedFolder(folderPath) && Settings.getBooleanProperty("davmail.excludePrivateEvents", true)) { LOGGER.debug("Shared or public calendar: exclude private events"); privateCondition = isEqualTo("sensitivity", 0); } return searchEvents(folderPath, getItemProperties(), and(filter, privateCondition)); } /** * Search calendar events or messages in provided folder matching the search query. * * @param folderPath Exchange folder path * @param attributes requested attributes * @param condition Exchange search query * @return list of calendar messages as Event objects * @throws IOException on error */ public abstract List searchEvents(String folderPath, Set attributes, Condition condition) throws IOException; /** * convert vcf extension to EML. * * @param itemName item name * @return EML item name */ protected String convertItemNameToEML(String itemName) { if (itemName.endsWith(".vcf")) { return itemName.substring(0, itemName.length() - 3) + "EML"; } else { return itemName; } } /** * Get item named eventName in folder * * @param folderPath Exchange folder path * @param itemName event name * @return event object * @throws IOException on error */ public abstract Item getItem(String folderPath, String itemName) throws IOException; /** * Contact picture */ public static class ContactPhoto { /** * Contact picture content type (always image/jpeg on read) */ public String contentType; /** * Base64 encoded picture content */ public String content; } /** * Retrieve contact photo attached to contact * * @param contact address book contact * @return contact photo * @throws IOException on error */ public abstract ContactPhoto getContactPhoto(Contact contact) throws IOException; /** * Delete event named itemName in folder * * @param folderPath Exchange folder path * @param itemName item name * @throws IOException on error */ public abstract void deleteItem(String folderPath, String itemName) throws IOException; /** * Mark event processed named eventName in folder * * @param folderPath Exchange folder path * @param itemName item name * @throws IOException on error */ public abstract void processItem(String folderPath, String itemName) throws IOException; private static int dumpIndex; /** * Replace iCal4 (Snow Leopard) principal paths with mailto expression * * @param value attendee value or ics line * @return fixed value */ protected String replaceIcal4Principal(String value) { if (value != null && value.contains("/principals/__uuids__/")) { return value.replaceAll("/principals/__uuids__/([^/]*)__AT__([^/]*)/", "mailto:$1@$2"); } else { return value; } } /** * Event result object to hold HTTP status and event etag from an event creation/update. */ public static class ItemResult { /** * HTTP status */ public int status; /** * Event etag from response HTTP header */ public String etag; } /** * Build and send the MIME message for the provided ICS event. * * @param icsBody event in iCalendar format * @return HTTP status * @throws IOException on error */ public abstract int sendEvent(String icsBody) throws IOException; /** * Create or update item (event or contact) on the Exchange server * * @param folderPath Exchange folder path * @param itemName event name * @param itemBody event body in iCalendar format * @param etag previous event etag to detect concurrent updates * @param noneMatch if-none-match header value * @return HTTP response event result (status and etag) * @throws IOException on error */ public ItemResult createOrUpdateItem(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException { if (itemBody.startsWith("BEGIN:VCALENDAR")) { return internalCreateOrUpdateEvent(folderPath, itemName, "urn:content-classes:appointment", itemBody, etag, noneMatch); } else if (itemBody.startsWith("BEGIN:VCARD")) { return createOrUpdateContact(folderPath, itemName, itemBody, etag, noneMatch); } else { throw new IOException(BundleMessage.format("EXCEPTION_INVALID_MESSAGE_CONTENT", itemBody)); } } static final String[] VCARD_N_PROPERTIES = {"sn", "givenName", "middlename", "personaltitle", "namesuffix"}; static final String[] VCARD_ADR_HOME_PROPERTIES = {"homepostofficebox", null, "homeStreet", "homeCity", "homeState", "homePostalCode", "homeCountry"}; static final String[] VCARD_ADR_WORK_PROPERTIES = {"postofficebox", "roomnumber", "street", "l", "st", "postalcode", "co"}; static final String[] VCARD_ADR_OTHER_PROPERTIES = {"otherpostofficebox", null, "otherstreet", "othercity", "otherstate", "otherpostalcode", "othercountry"}; static final String[] VCARD_ORG_PROPERTIES = {"o", "department"}; protected void convertContactProperties(Map properties, String[] contactProperties, List values) { for (int i = 0; i < values.size() && i < contactProperties.length; i++) { if (contactProperties[i] != null) { properties.put(contactProperties[i], values.get(i)); } } } protected ItemResult createOrUpdateContact(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException { // parse VCARD body to build contact property map Map properties = new HashMap(); properties.put("outlookmessageclass", "IPM.Contact"); VObject vcard = new VObject(new ICSBufferedReader(new StringReader(itemBody))); for (VProperty property : vcard.getProperties()) { if ("FN".equals(property.getKey())) { properties.put("cn", property.getValue()); properties.put("subject", property.getValue()); properties.put("fileas", property.getValue()); } else if ("N".equals(property.getKey())) { convertContactProperties(properties, VCARD_N_PROPERTIES, property.getValues()); } else if ("NICKNAME".equals(property.getKey())) { properties.put("nickname", property.getValue()); } else if ("TEL".equals(property.getKey())) { if (property.hasParam("TYPE", "cell") || property.hasParam("X-GROUP", "cell")) { properties.put("mobile", property.getValue()); } else if (property.hasParam("TYPE", "work") || property.hasParam("X-GROUP", "work")) { properties.put("telephoneNumber", property.getValue()); } else if (property.hasParam("TYPE", "home") || property.hasParam("X-GROUP", "home")) { properties.put("homePhone", property.getValue()); } else if (property.hasParam("TYPE", "fax")) { if (property.hasParam("TYPE", "home")) { properties.put("homefax", property.getValue()); } else { properties.put("facsimiletelephonenumber", property.getValue()); } } else if (property.hasParam("TYPE", "pager")) { properties.put("pager", property.getValue()); } else if (property.hasParam("TYPE", "car")) { properties.put("othermobile", property.getValue()); } else { properties.put("otherTelephone", property.getValue()); } } else if ("ADR".equals(property.getKey())) { // address if (property.hasParam("TYPE", "home")) { convertContactProperties(properties, VCARD_ADR_HOME_PROPERTIES, property.getValues()); } else if (property.hasParam("TYPE", "work")) { convertContactProperties(properties, VCARD_ADR_WORK_PROPERTIES, property.getValues()); // any other type goes to other address } else { convertContactProperties(properties, VCARD_ADR_OTHER_PROPERTIES, property.getValues()); } } else if ("EMAIL".equals(property.getKey())) { if (property.hasParam("TYPE", "home")) { properties.put("email2", property.getValue()); properties.put("smtpemail2", property.getValue()); } else if (property.hasParam("TYPE", "other")) { properties.put("email3", property.getValue()); properties.put("smtpemail3", property.getValue()); } else { properties.put("email1", property.getValue()); properties.put("smtpemail1", property.getValue()); } } else if ("ORG".equals(property.getKey())) { convertContactProperties(properties, VCARD_ORG_PROPERTIES, property.getValues()); } else if ("URL".equals(property.getKey())) { if (property.hasParam("TYPE", "work")) { properties.put("businesshomepage", property.getValue()); } else if (property.hasParam("TYPE", "home")) { properties.put("personalHomePage", property.getValue()); } else { // default: set personal home page properties.put("personalHomePage", property.getValue()); } } else if ("TITLE".equals(property.getKey())) { properties.put("title", property.getValue()); } else if ("NOTE".equals(property.getKey())) { properties.put("description", property.getValue()); } else if ("CUSTOM1".equals(property.getKey())) { properties.put("extensionattribute1", property.getValue()); } else if ("CUSTOM2".equals(property.getKey())) { properties.put("extensionattribute2", property.getValue()); } else if ("CUSTOM3".equals(property.getKey())) { properties.put("extensionattribute3", property.getValue()); } else if ("CUSTOM4".equals(property.getKey())) { properties.put("extensionattribute4", property.getValue()); } else if ("ROLE".equals(property.getKey())) { properties.put("profession", property.getValue()); } else if ("X-AIM".equals(property.getKey())) { properties.put("im", property.getValue()); } else if ("BDAY".equals(property.getKey())) { properties.put("bday", convertBDayToZulu(property.getValue())); } else if ("ANNIVERSARY".equals(property.getKey()) || "X-ANNIVERSARY".equals(property.getKey())) { properties.put("anniversary", convertBDayToZulu(property.getValue())); } else if ("CATEGORIES".equals(property.getKey())) { properties.put("keywords", property.getValue()); } else if ("CLASS".equals(property.getKey())) { if ("PUBLIC".equals(property.getValue())) { properties.put("sensitivity", "0"); properties.put("private", "false"); } else { properties.put("sensitivity", "2"); properties.put("private", "true"); } } else if ("SEX".equals(property.getKey())) { String propertyValue = property.getValue(); if ("1".equals(propertyValue)) { properties.put("gender", "2"); } else if ("2".equals(propertyValue)) { properties.put("gender", "1"); } } else if ("FBURL".equals(property.getKey())) { properties.put("fburl", property.getValue()); } else if ("X-ASSISTANT".equals(property.getKey())) { properties.put("secretarycn", property.getValue()); } else if ("X-MANAGER".equals(property.getKey())) { properties.put("manager", property.getValue()); } else if ("X-SPOUSE".equals(property.getKey())) { properties.put("spousecn", property.getValue()); } else if ("PHOTO".equals(property.getKey())) { properties.put("photo", property.getValue()); properties.put("haspicture", "true"); } } LOGGER.debug("Create or update contact " + itemName + ": " + properties); // reset missing properties to null for (String key : CONTACT_ATTRIBUTES) { if (!"imapUid".equals(key) && !"etag".equals(key) && !"urlcompname".equals(key) && !"lastmodified".equals(key) && !"sensitivity".equals(key) && !properties.containsKey(key)) { properties.put(key, null); } } return internalCreateOrUpdateContact(folderPath, itemName, properties, etag, noneMatch); } protected String convertZuluDateToBday(String value) { String result = null; if (value != null && value.length() > 0) { try { SimpleDateFormat parser = ExchangeSession.getZuluDateFormat(); Calendar cal = Calendar.getInstance(); cal.setTime(parser.parse(value)); cal.add(Calendar.HOUR_OF_DAY, 12); result = ExchangeSession.getVcardBdayFormat().format(cal.getTime()); } catch (ParseException e) { LOGGER.warn("Invalid date: " + value); } } return result; } protected String convertBDayToZulu(String value) { String result = null; if (value != null && value.length() > 0) { try { SimpleDateFormat parser; if (value.length() == 10) { parser = ExchangeSession.getVcardBdayFormat(); } else if (value.length() == 15) { parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ENGLISH); parser.setTimeZone(GMT_TIMEZONE); } else { parser = ExchangeSession.getExchangeZuluDateFormat(); } result = ExchangeSession.getExchangeZuluDateFormatMillisecond().format(parser.parse(value)); } catch (ParseException e) { LOGGER.warn("Invalid date: " + value); } } return result; } protected abstract ItemResult internalCreateOrUpdateContact(String folderPath, String itemName, Map properties, String etag, String noneMatch) throws IOException; protected abstract ItemResult internalCreateOrUpdateEvent(String folderPath, String itemName, String contentClass, String icsBody, String etag, String noneMatch) throws IOException; /** * Get current Exchange alias name from login name * * @return user name */ public String getAliasFromLogin() { // login is email, not alias if (this.userName.indexOf('@') >= 0) { return null; } String result = this.userName; // remove domain name int index = Math.max(result.indexOf('\\'), result.indexOf('/')); if (index >= 0) { result = result.substring(index + 1); } return result; } protected String getEmailSuffixFromHostname() { String domain = httpClient.getHostConfiguration().getHost(); int start = domain.lastIndexOf('.', domain.lastIndexOf('.') - 1); if (start >= 0) { return '@' + domain.substring(start + 1); } else { return '@' + domain; } } /** * Test if folderPath is inside user mailbox. * * @param folderPath absolute folder path * @return true if folderPath is a public or shared folder */ public abstract boolean isSharedFolder(String folderPath); /** * Test if folderPath is main calendar. * * @param folderPath absolute folder path * @return true if folderPath is a public or shared folder */ public abstract boolean isMainCalendar(String folderPath); static final String MAILBOX_BASE = "/cn="; protected void getEmailAndAliasFromOptions() { synchronized (httpClient.getState()) { Cookie[] currentCookies = httpClient.getState().getCookies(); // get user mail URL from html body BufferedReader optionsPageReader = null; GetMethod optionsMethod = new GetMethod("/owa/?ae=Options&t=About"); try { DavGatewayHttpClientFacade.executeGetMethod(httpClient, optionsMethod, false); optionsPageReader = new BufferedReader(new InputStreamReader(optionsMethod.getResponseBodyAsStream())); String line; // find email and alias //noinspection StatementWithEmptyBody while ((line = optionsPageReader.readLine()) != null && (line.indexOf('[') == -1 || line.indexOf('@') == -1 || line.indexOf(']') == -1) && !line.toLowerCase().contains(MAILBOX_BASE)) { } if (line != null) { int start = line.toLowerCase().lastIndexOf(MAILBOX_BASE) + MAILBOX_BASE.length(); int end = line.indexOf('<', start); alias = line.substring(start, end); end = line.lastIndexOf(']'); start = line.lastIndexOf('[', end) + 1; email = line.substring(start, end); } } catch (IOException e) { // restore cookies on error httpClient.getState().addCookies(currentCookies); LOGGER.error("Error parsing options page at " + optionsMethod.getPath()); } finally { if (optionsPageReader != null) { try { optionsPageReader.close(); } catch (IOException e) { LOGGER.error("Error parsing options page at " + optionsMethod.getPath()); } } optionsMethod.releaseConnection(); } } } /** * Get current user email * * @return user email */ public String getEmail() { return email; } /** * Get current user alias * * @return user email */ public String getAlias() { return alias; } /** * Search global address list * * @param condition search filter * @param returningAttributes returning attributes * @param sizeLimit size limit * @return matching contacts from gal * @throws IOException on error */ public abstract Map galFind(Condition condition, Set returningAttributes, int sizeLimit) throws IOException; /** * Full Contact attribute list */ public static final Set CONTACT_ATTRIBUTES = new HashSet(); static { CONTACT_ATTRIBUTES.add("imapUid"); CONTACT_ATTRIBUTES.add("etag"); CONTACT_ATTRIBUTES.add("urlcompname"); CONTACT_ATTRIBUTES.add("extensionattribute1"); CONTACT_ATTRIBUTES.add("extensionattribute2"); CONTACT_ATTRIBUTES.add("extensionattribute3"); CONTACT_ATTRIBUTES.add("extensionattribute4"); CONTACT_ATTRIBUTES.add("bday"); CONTACT_ATTRIBUTES.add("anniversary"); CONTACT_ATTRIBUTES.add("businesshomepage"); CONTACT_ATTRIBUTES.add("personalHomePage"); CONTACT_ATTRIBUTES.add("cn"); CONTACT_ATTRIBUTES.add("co"); CONTACT_ATTRIBUTES.add("department"); CONTACT_ATTRIBUTES.add("smtpemail1"); CONTACT_ATTRIBUTES.add("smtpemail2"); CONTACT_ATTRIBUTES.add("smtpemail3"); CONTACT_ATTRIBUTES.add("facsimiletelephonenumber"); CONTACT_ATTRIBUTES.add("givenName"); CONTACT_ATTRIBUTES.add("homeCity"); CONTACT_ATTRIBUTES.add("homeCountry"); CONTACT_ATTRIBUTES.add("homePhone"); CONTACT_ATTRIBUTES.add("homePostalCode"); CONTACT_ATTRIBUTES.add("homeState"); CONTACT_ATTRIBUTES.add("homeStreet"); CONTACT_ATTRIBUTES.add("homepostofficebox"); CONTACT_ATTRIBUTES.add("l"); CONTACT_ATTRIBUTES.add("manager"); CONTACT_ATTRIBUTES.add("mobile"); CONTACT_ATTRIBUTES.add("namesuffix"); CONTACT_ATTRIBUTES.add("nickname"); CONTACT_ATTRIBUTES.add("o"); CONTACT_ATTRIBUTES.add("pager"); CONTACT_ATTRIBUTES.add("personaltitle"); CONTACT_ATTRIBUTES.add("postalcode"); CONTACT_ATTRIBUTES.add("postofficebox"); CONTACT_ATTRIBUTES.add("profession"); CONTACT_ATTRIBUTES.add("roomnumber"); CONTACT_ATTRIBUTES.add("secretarycn"); CONTACT_ATTRIBUTES.add("sn"); CONTACT_ATTRIBUTES.add("spousecn"); CONTACT_ATTRIBUTES.add("st"); CONTACT_ATTRIBUTES.add("street"); CONTACT_ATTRIBUTES.add("telephoneNumber"); CONTACT_ATTRIBUTES.add("title"); CONTACT_ATTRIBUTES.add("description"); CONTACT_ATTRIBUTES.add("im"); CONTACT_ATTRIBUTES.add("middlename"); CONTACT_ATTRIBUTES.add("lastmodified"); CONTACT_ATTRIBUTES.add("otherstreet"); CONTACT_ATTRIBUTES.add("otherstate"); CONTACT_ATTRIBUTES.add("otherpostofficebox"); CONTACT_ATTRIBUTES.add("otherpostalcode"); CONTACT_ATTRIBUTES.add("othercountry"); CONTACT_ATTRIBUTES.add("othercity"); CONTACT_ATTRIBUTES.add("haspicture"); CONTACT_ATTRIBUTES.add("keywords"); CONTACT_ATTRIBUTES.add("othermobile"); CONTACT_ATTRIBUTES.add("otherTelephone"); CONTACT_ATTRIBUTES.add("gender"); CONTACT_ATTRIBUTES.add("private"); CONTACT_ATTRIBUTES.add("sensitivity"); CONTACT_ATTRIBUTES.add("fburl"); } /** * Get freebusy data string from Exchange. * * @param attendee attendee email address * @param start start date in Exchange zulu format * @param end end date in Exchange zulu format * @param interval freebusy interval in minutes * @return freebusy data or null * @throws IOException on error */ protected abstract String getFreeBusyData(String attendee, String start, String end, int interval) throws IOException; /** * Get freebusy info for attendee between start and end date. * * @param attendee attendee email * @param startDateValue start date * @param endDateValue end date * @return FreeBusy info * @throws IOException on error */ public FreeBusy getFreebusy(String attendee, String startDateValue, String endDateValue) throws IOException { // replace ical encoded attendee name attendee = replaceIcal4Principal(attendee); // then check that email address is valid to avoid InvalidSmtpAddress error if (attendee == null || attendee.indexOf('@') < 0 || attendee.charAt(attendee.length() - 1) == '@') { return null; } if (attendee.startsWith("mailto:") || attendee.startsWith("MAILTO:")) { attendee = attendee.substring("mailto:".length()); } SimpleDateFormat exchangeZuluDateFormat = getExchangeZuluDateFormat(); SimpleDateFormat icalDateFormat = getZuluDateFormat(); Date startDate; Date endDate; try { if (startDateValue.length() == 8) { startDate = parseDate(startDateValue); } else { startDate = icalDateFormat.parse(startDateValue); } if (endDateValue.length() == 8) { endDate = parseDate(endDateValue); } else { endDate = icalDateFormat.parse(endDateValue); } } catch (ParseException e) { throw new DavMailException("EXCEPTION_INVALID_DATES", e.getMessage()); } FreeBusy freeBusy = null; String fbdata = getFreeBusyData(attendee, exchangeZuluDateFormat.format(startDate), exchangeZuluDateFormat.format(endDate), FREE_BUSY_INTERVAL); if (fbdata != null) { freeBusy = new FreeBusy(icalDateFormat, startDate, fbdata); } if (freeBusy != null && freeBusy.knownAttendee) { return freeBusy; } else { return null; } } /** * Exchange to iCalendar Free/Busy parser. * Free time returns 0, Tentative returns 1, Busy returns 2, and Out of Office (OOF) returns 3 */ public static final class FreeBusy { final SimpleDateFormat icalParser; boolean knownAttendee = true; static final HashMap FBTYPES = new HashMap(); static { FBTYPES.put('1', "BUSY-TENTATIVE"); FBTYPES.put('2', "BUSY"); FBTYPES.put('3', "BUSY-UNAVAILABLE"); } final HashMap busyMap = new HashMap(); StringBuilder getBusyBuffer(char type) { String fbType = FBTYPES.get(Character.valueOf(type)); StringBuilder buffer = busyMap.get(fbType); if (buffer == null) { buffer = new StringBuilder(); busyMap.put(fbType, buffer); } return buffer; } void startBusy(char type, Calendar currentCal) { if (type == '4') { knownAttendee = false; } else if (type != '0') { StringBuilder busyBuffer = getBusyBuffer(type); if (busyBuffer.length() > 0) { busyBuffer.append(','); } busyBuffer.append(icalParser.format(currentCal.getTime())); } } void endBusy(char type, Calendar currentCal) { if (type != '0' && type != '4') { getBusyBuffer(type).append('/').append(icalParser.format(currentCal.getTime())); } } FreeBusy(SimpleDateFormat icalParser, Date startDate, String fbdata) { this.icalParser = icalParser; if (fbdata.length() > 0) { Calendar currentCal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); currentCal.setTime(startDate); startBusy(fbdata.charAt(0), currentCal); for (int i = 1; i < fbdata.length() && knownAttendee; i++) { currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL); char previousState = fbdata.charAt(i - 1); char currentState = fbdata.charAt(i); if (previousState != currentState) { endBusy(previousState, currentCal); startBusy(currentState, currentCal); } } currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL); endBusy(fbdata.charAt(fbdata.length() - 1), currentCal); } } /** * Append freebusy information to buffer. * * @param buffer String buffer */ public void appendTo(StringBuilder buffer) { for (Map.Entry entry : busyMap.entrySet()) { buffer.append("FREEBUSY;FBTYPE=").append(entry.getKey()) .append(':').append(entry.getValue()).append((char) 13).append((char) 10); } } } protected VObject vTimezone; /** * Load and return current user OWA timezone. * * @return current timezone */ public VObject getVTimezone() { if (vTimezone == null) { // need to load Timezone info from OWA loadVtimezone(); } return vTimezone; } protected abstract void loadVtimezone(); /** * Return internal HttpClient instance * * @return http client */ public HttpClient getHttpClient() { return httpClient; } }