View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2009  Mickael Guessant
4    *
5    * This program is free software; you can redistribute it and/or
6    * modify it under the terms of the GNU General Public License
7    * as published by the Free Software Foundation; either version 2
8    * of the License, or (at your option) any later version.
9    *
10   * This program is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with this program; if not, write to the Free Software
17   * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18   */
19  package davmail.exchange;
20  
21  import davmail.BundleMessage;
22  import davmail.Settings;
23  import davmail.exception.DavMailAuthenticationException;
24  import davmail.exception.DavMailException;
25  import davmail.exception.WebdavNotAvailableException;
26  import davmail.http.DavGatewayHttpClientFacade;
27  import davmail.http.DavGatewayOTPPrompt;
28  import davmail.ui.NotificationDialog;
29  import davmail.util.StringUtil;
30  import org.apache.commons.httpclient.*;
31  import org.apache.commons.httpclient.methods.GetMethod;
32  import org.apache.commons.httpclient.methods.PostMethod;
33  import org.apache.commons.httpclient.params.HttpClientParams;
34  import org.apache.commons.httpclient.util.URIUtil;
35  import org.apache.log4j.Logger;
36  import org.htmlcleaner.CommentNode;
37  import org.htmlcleaner.ContentNode;
38  import org.htmlcleaner.HtmlCleaner;
39  import org.htmlcleaner.TagNode;
40  
41  import javax.imageio.ImageIO;
42  import javax.mail.MessagingException;
43  import javax.mail.internet.*;
44  import javax.mail.util.SharedByteArrayInputStream;
45  import java.awt.image.BufferedImage;
46  import java.io.*;
47  import java.net.ConnectException;
48  import java.net.NoRouteToHostException;
49  import java.net.UnknownHostException;
50  import java.text.ParseException;
51  import java.text.SimpleDateFormat;
52  import java.util.*;
53  
54  /**
55   * Exchange session through Outlook Web Access (DAV)
56   */
57  public abstract class ExchangeSession {
58  
59      protected static final Logger LOGGER = Logger.getLogger("davmail.exchange.ExchangeSession");
60  
61      /**
62       * Reference GMT timezone to format dates
63       */
64      public static final SimpleTimeZone GMT_TIMEZONE = new SimpleTimeZone(0, "GMT");
65  
66      protected static final Set<String> USER_NAME_FIELDS = new HashSet<String>();
67  
68      static {
69          USER_NAME_FIELDS.add("username");
70          USER_NAME_FIELDS.add("txtUserName");
71          USER_NAME_FIELDS.add("userid");
72          USER_NAME_FIELDS.add("SafeWordUser");
73          USER_NAME_FIELDS.add("user_name");
74          USER_NAME_FIELDS.add("login");
75      }
76  
77      protected static final Set<String> PASSWORD_FIELDS = new HashSet<String>();
78  
79      static {
80          PASSWORD_FIELDS.add("password");
81          PASSWORD_FIELDS.add("txtUserPass");
82          PASSWORD_FIELDS.add("pw");
83          PASSWORD_FIELDS.add("basicPassword");
84          PASSWORD_FIELDS.add("passwd");
85      }
86  
87      protected static final Set<String> TOKEN_FIELDS = new HashSet<String>();
88  
89      static {
90          TOKEN_FIELDS.add("SafeWordPassword");
91          TOKEN_FIELDS.add("passcode");
92      }
93  
94      protected static final int FREE_BUSY_INTERVAL = 15;
95  
96      protected static final String PUBLIC_ROOT = "/public/";
97      protected static final String CALENDAR = "calendar";
98      protected static final String TASKS = "tasks";
99      /**
100      * Contacts folder logical name
101      */
102     public static final String CONTACTS = "contacts";
103     protected static final String ADDRESSBOOK = "addressbook";
104     protected static final String INBOX = "INBOX";
105     protected static final String LOWER_CASE_INBOX = "inbox";
106     protected static final String SENT = "Sent";
107     protected static final String SENDMSG = "##DavMailSubmissionURI##";
108     protected static final String DRAFTS = "Drafts";
109     protected static final String TRASH = "Trash";
110     protected static final String JUNK = "Junk";
111     protected static final String UNSENT = "Unsent Messages";
112 
113     static {
114         // Adjust Mime decoder settings
115         System.setProperty("mail.mime.ignoreunknownencoding", "true");
116         System.setProperty("mail.mime.decodetext.strict", "false");
117     }
118 
119     protected String publicFolderUrl;
120 
121     /**
122      * Base user mailboxes path (used to select folder)
123      */
124     protected String mailPath;
125     protected String rootPath;
126     protected String email;
127     protected String alias;
128     /**
129      * Lower case Caldav path to current user mailbox.
130      * /users/<i>email</i>
131      */
132     protected String currentMailboxPath;
133     protected final HttpClient httpClient;
134 
135     protected String userName;
136     /**
137      * A OTP pre-auth page may require a different username.
138      */
139     private String preAuthUsername;
140 
141     protected String serverVersion;
142 
143     protected static final String YYYY_MM_DD_HH_MM_SS = "yyyy/MM/dd HH:mm:ss";
144     private static final String YYYYMMDD_T_HHMMSS_Z = "yyyyMMdd'T'HHmmss'Z'";
145     protected static final String YYYY_MM_DD_T_HHMMSS_Z = "yyyy-MM-dd'T'HH:mm:ss'Z'";
146     private static final String YYYY_MM_DD = "yyyy-MM-dd";
147     private static final String YYYY_MM_DD_T_HHMMSS_SSS_Z = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'";
148 
149     /**
150      * Logon form user name fields.
151      */
152     private final List<String> userNameInputs = new ArrayList<String>();
153     /**
154      * Logon form password field, default is password.
155      */
156     private String passwordInput = null;
157     /**
158      * Tells if, during the login navigation, an OTP pre-auth page has been found.
159      */
160     private boolean otpPreAuthFound = false;
161     /**
162      * Lets the user try again a couple of times to enter the OTP pre-auth key before giving up.
163      */
164     private int otpPreAuthRetries = 0;
165     /**
166      * Maximum number of times the user can try to input again the OTP pre-auth key before giving up.
167      */
168     private static final int MAX_OTP_RETRIES = 3;
169 
170     /**
171      * Build an ExchangeSession from an already authenticated HttpClient.
172      *
173      * @param httpClient httpClient instance with session cookies
174      * @param userName User name
175      */
176     public ExchangeSession(HttpClient httpClient, String userName) throws DavMailException {
177         this.httpClient = httpClient;
178         this.userName = userName;
179         buildSessionInfo(null);
180     }
181 
182     /**
183      * Create an exchange session for the given URL.
184      * The session is established for given userName and password
185      *
186      * @param url      Exchange url
187      * @param userName user login name
188      * @param password user password
189      * @throws IOException on error
190      */
191     public ExchangeSession(String url, String userName, String password) throws IOException {
192         this.userName = userName;
193         try {
194             httpClient = DavGatewayHttpClientFacade.getInstance(url);
195             // set private connection pool
196             DavGatewayHttpClientFacade.createMultiThreadedHttpConnectionManager(httpClient);
197             boolean isBasicAuthentication = isBasicAuthentication(httpClient, url);
198             // clear cookies created by authentication test
199             httpClient.getState().clearCookies();
200 
201             // The user may have configured an OTP pre-auth username. It is processed
202             // so early because OTP pre-auth may disappear in the Exchange LAN and this
203             // helps the user to not change is account settings in mail client at each network change.
204             if (preAuthUsername == null) {
205                 // Searches for the delimiter in configured username for the pre-auth user. 
206                 // The double-quote is not allowed inside email addresses anyway.
207                 int doubleQuoteIndex = this.userName.indexOf('"');
208                 if (doubleQuoteIndex > 0) {
209                     preAuthUsername = this.userName.substring(0, doubleQuoteIndex);
210                     this.userName = this.userName.substring(doubleQuoteIndex + 1);
211                 } else {
212                     // No doublequote: the pre-auth user is the full username, or it is not used at all.
213                     preAuthUsername = this.userName;
214                 }
215             }
216 
217             DavGatewayHttpClientFacade.setCredentials(httpClient, userName, password);
218 
219             // get webmail root url
220             // providing credentials
221             // manually follow redirect
222             HttpMethod method = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, url);
223 
224             if (!this.isAuthenticated(method)) {
225                 if (isBasicAuthentication) {
226                     int status = method.getStatusCode();
227 
228                     if (status == HttpStatus.SC_UNAUTHORIZED) {
229                         method.releaseConnection();
230                         throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
231                     } else if (status != HttpStatus.SC_OK) {
232                         method.releaseConnection();
233                         throw DavGatewayHttpClientFacade.buildHttpException(method);
234                     }
235                     // workaround for basic authentication on /exchange and form based authentication at /owa
236                     if ("/owa/auth/logon.aspx".equals(method.getPath())) {
237                         method = formLogin(httpClient, method, userName, password);
238                     }
239                 } else {
240                     method = formLogin(httpClient, method, userName, password);
241                 }
242             }
243 
244             // avoid 401 roundtrips, only if NTLM is disabled and basic authentication enabled
245             if (isBasicAuthentication && !DavGatewayHttpClientFacade.hasNTLMorNegotiate(httpClient)) {
246                 httpClient.getParams().setParameter(HttpClientParams.PREEMPTIVE_AUTHENTICATION, true);
247             }
248 
249             buildSessionInfo(method);
250 
251         } catch (DavMailAuthenticationException exc) {
252             LOGGER.error(exc.getMessage());
253             throw exc;
254         } catch (ConnectException exc) {
255             BundleMessage message = new BundleMessage("EXCEPTION_CONNECT", exc.getClass().getName(), exc.getMessage());
256             ExchangeSession.LOGGER.error(message);
257             throw new DavMailException("EXCEPTION_DAVMAIL_CONFIGURATION", message);
258         } catch (UnknownHostException exc) {
259             BundleMessage message = new BundleMessage("EXCEPTION_CONNECT", exc.getClass().getName(), exc.getMessage());
260             ExchangeSession.LOGGER.error(message);
261             throw new DavMailException("EXCEPTION_DAVMAIL_CONFIGURATION", message);
262         } catch (WebdavNotAvailableException exc) {
263             throw exc;
264         } catch (IOException exc) {
265             LOGGER.error(BundleMessage.formatLog("EXCEPTION_EXCHANGE_LOGIN_FAILED", exc));
266             throw new DavMailException("EXCEPTION_EXCHANGE_LOGIN_FAILED", exc);
267         }
268         LOGGER.debug("Session " + this + " created");
269     }
270 
271     /**
272      * Format date to exchange search format.
273      *
274      * @param date date object
275      * @return formatted search date
276      */
277     public abstract String formatSearchDate(Date date);
278 
279     /**
280      * Return standard zulu date formatter.
281      *
282      * @return zulu date formatter
283      */
284     public static SimpleDateFormat getZuluDateFormat() {
285         SimpleDateFormat dateFormat = new SimpleDateFormat(YYYYMMDD_T_HHMMSS_Z, Locale.ENGLISH);
286         dateFormat.setTimeZone(GMT_TIMEZONE);
287         return dateFormat;
288     }
289 
290     protected static SimpleDateFormat getVcardBdayFormat() {
291         SimpleDateFormat dateFormat = new SimpleDateFormat(YYYY_MM_DD, Locale.ENGLISH);
292         dateFormat.setTimeZone(GMT_TIMEZONE);
293         return dateFormat;
294     }
295 
296     protected static SimpleDateFormat getExchangeZuluDateFormat() {
297         SimpleDateFormat dateFormat = new SimpleDateFormat(YYYY_MM_DD_T_HHMMSS_Z, Locale.ENGLISH);
298         dateFormat.setTimeZone(GMT_TIMEZONE);
299         return dateFormat;
300     }
301 
302     protected static SimpleDateFormat getExchangeZuluDateFormatMillisecond() {
303         SimpleDateFormat dateFormat = new SimpleDateFormat(YYYY_MM_DD_T_HHMMSS_SSS_Z, Locale.ENGLISH);
304         dateFormat.setTimeZone(GMT_TIMEZONE);
305         return dateFormat;
306     }
307 
308     protected static Date parseDate(String dateString) throws ParseException {
309         SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
310         dateFormat.setTimeZone(GMT_TIMEZONE);
311         return dateFormat.parse(dateString);
312     }
313 
314 
315     /**
316      * Test if the session expired.
317      *
318      * @return true this session expired
319      * @throws NoRouteToHostException on error
320      * @throws UnknownHostException   on error
321      */
322     public boolean isExpired() throws NoRouteToHostException, UnknownHostException {
323         boolean isExpired = false;
324         try {
325             getFolder("");
326         } catch (UnknownHostException exc) {
327             throw exc;
328         } catch (NoRouteToHostException exc) {
329             throw exc;
330         } catch (IOException e) {
331             isExpired = true;
332         }
333 
334         return isExpired;
335     }
336 
337     /**
338      * Test authentication mode : form based or basic.
339      *
340      * @param url        exchange base URL
341      * @param httpClient httpClient instance
342      * @return true if basic authentication detected
343      */
344     protected boolean isBasicAuthentication(HttpClient httpClient, String url) {
345         return DavGatewayHttpClientFacade.getHttpStatus(httpClient, url) == HttpStatus.SC_UNAUTHORIZED;
346     }
347 
348     protected String getAbsoluteUri(HttpMethod method, String path) throws URIException {
349         URI uri = method.getURI();
350         if (path != null) {
351             // reset query string
352             uri.setQuery(null);
353             if (path.startsWith("/")) {
354                 // path is absolute, replace method path
355                 uri.setPath(path);
356             } else if (path.startsWith("http://") || path.startsWith("https://")) {
357                 return path;
358             } else {
359                 // relative path, build new path
360                 String currentPath = method.getPath();
361                 int end = currentPath.lastIndexOf('/');
362                 if (end >= 0) {
363                     uri.setPath(currentPath.substring(0, end + 1) + path);
364                 } else {
365                     throw new URIException(uri.getURI());
366                 }
367             }
368         }
369         return uri.getURI();
370     }
371 
372     protected String getScriptBasedFormURL(HttpMethod initmethod, String pathQuery) throws URIException {
373         URI initmethodURI = initmethod.getURI();
374         int queryIndex = pathQuery.indexOf('?');
375         if (queryIndex >= 0) {
376             if (queryIndex > 0) {
377                 // update path
378                 String newPath = pathQuery.substring(0, queryIndex);
379                 if (newPath.startsWith("/")) {
380                     // absolute path
381                     initmethodURI.setPath(newPath);
382                 } else {
383                     String currentPath = initmethodURI.getPath();
384                     int folderIndex = currentPath.lastIndexOf('/');
385                     if (folderIndex >= 0) {
386                         // replace relative path
387                         initmethodURI.setPath(currentPath.substring(0, folderIndex + 1) + newPath);
388                     } else {
389                         // should not happen
390                         initmethodURI.setPath('/' + newPath);
391                     }
392                 }
393             }
394             initmethodURI.setQuery(pathQuery.substring(queryIndex + 1));
395         }
396         return initmethodURI.getURI();
397     }
398 
399     /**
400      * Try to find logon method path from logon form body.
401      *
402      * @param httpClient httpClient instance
403      * @param initmethod form body http method
404      * @return logon method
405      * @throws IOException on error
406      */
407     protected HttpMethod buildLogonMethod(HttpClient httpClient, HttpMethod initmethod) throws IOException {
408 
409         HttpMethod logonMethod = null;
410 
411         // create an instance of HtmlCleaner
412         HtmlCleaner cleaner = new HtmlCleaner();
413 
414         // A OTP token authentication form in a previous page could have username fields with different names
415         userNameInputs.clear();
416 
417         try {
418             TagNode node = cleaner.clean(initmethod.getResponseBodyAsStream());
419             List forms = node.getElementListByName("form", true);
420             TagNode logonForm = null;
421             // select form
422             if (forms.size() == 1) {
423                 logonForm = (TagNode) forms.get(0);
424             } else if (forms.size() > 1) {
425                 for (Object form : forms) {
426                     if ("logonForm".equals(((TagNode) form).getAttributeByName("name"))) {
427                         logonForm = ((TagNode) form);
428                     }
429                 }
430             }
431             if (logonForm != null) {
432                 String logonMethodPath = logonForm.getAttributeByName("action");
433 
434                 // workaround for broken form with empty action
435                 if (logonMethodPath != null && logonMethodPath.length() == 0) {
436                     logonMethodPath = "/owa/auth.owa";
437                 }
438 
439                 logonMethod = new PostMethod(getAbsoluteUri(initmethod, logonMethodPath));
440 
441                 // retrieve lost inputs attached to body
442                 List inputList = node.getElementListByName("input", true);
443 
444                 for (Object input : inputList) {
445                     String type = ((TagNode) input).getAttributeByName("type");
446                     String name = ((TagNode) input).getAttributeByName("name");
447                     String value = ((TagNode) input).getAttributeByName("value");
448                     if ("hidden".equalsIgnoreCase(type) && name != null && value != null) {
449                         ((PostMethod) logonMethod).addParameter(name, value);
450                     }
451                     // custom login form
452                     if (USER_NAME_FIELDS.contains(name)) {
453                         userNameInputs.add(name);
454                     } else if (PASSWORD_FIELDS.contains(name)) {
455                         passwordInput = name;
456                     } else if ("addr".equals(name)) {
457                         // this is not a logon form but a redirect form
458                         HttpMethod newInitMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, logonMethod);
459                         logonMethod = buildLogonMethod(httpClient, newInitMethod);
460                     } else if (TOKEN_FIELDS.contains(name)) {
461                         // one time password, ask it to the user
462                         ((PostMethod) logonMethod).addParameter(name, DavGatewayOTPPrompt.getOneTimePassword());
463                     } else if ("otc".equals(name)) {
464                         // captcha image, get image and ask user
465                         String pinsafeUser = getAliasFromLogin();
466                         if (pinsafeUser == null) {
467                             pinsafeUser = userName;
468                         }
469                         GetMethod getMethod = new GetMethod("/PINsafeISAFilter.dll?username=" + pinsafeUser);
470                         try {
471                             int status = httpClient.executeMethod(getMethod);
472                             if (status != HttpStatus.SC_OK) {
473                                 throw DavGatewayHttpClientFacade.buildHttpException(getMethod);
474                             }
475                             BufferedImage captchaImage = ImageIO.read(getMethod.getResponseBodyAsStream());
476                             ((PostMethod) logonMethod).addParameter(name, DavGatewayOTPPrompt.getCaptchaValue(captchaImage));
477 
478                         } finally {
479                             getMethod.releaseConnection();
480                         }
481                     }
482                 }
483             } else {
484                 List frameList = node.getElementListByName("frame", true);
485                 if (frameList.size() == 1) {
486                     String src = ((TagNode) frameList.get(0)).getAttributeByName("src");
487                     if (src != null) {
488                         LOGGER.debug("Frames detected in form page, try frame content");
489                         initmethod.releaseConnection();
490                         HttpMethod newInitMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, src);
491                         logonMethod = buildLogonMethod(httpClient, newInitMethod);
492                     }
493                 } else {
494                     // another failover for script based logon forms (Exchange 2007)
495                     List scriptList = node.getElementListByName("script", true);
496                     for (Object script : scriptList) {
497                         List contents = ((TagNode) script).getChildren();
498                         for (Object content : contents) {
499                             if (content instanceof CommentNode) {
500                                 String scriptValue = ((CommentNode) content).getCommentedContent();
501                                 String sUrl = StringUtil.getToken(scriptValue, "var a_sUrl = \"", "\"");
502                                 String sLgn = StringUtil.getToken(scriptValue, "var a_sLgnQS = \"", "\"");
503                                 if (sLgn == null) {
504                                     sLgn = StringUtil.getToken(scriptValue, "var a_sLgn = \"", "\"");
505                                 }
506                                 if (sUrl != null && sLgn != null) {
507                                     String src = getScriptBasedFormURL(initmethod, sLgn + sUrl);
508                                     LOGGER.debug("Detected script based logon, redirect to form at " + src);
509                                     HttpMethod newInitMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, src);
510                                     logonMethod = buildLogonMethod(httpClient, newInitMethod);
511                                 }
512 
513                             } else if (content instanceof ContentNode) {
514                                 // Microsoft Forefront Unified Access Gateway redirect
515                                 String scriptValue = ((ContentNode) content).getContent().toString();
516                                 String location = StringUtil.getToken(scriptValue, "window.location.replace(\"", "\"");
517                                 if (location != null) {
518                                     LOGGER.debug("Post logon redirect to: " + location);
519                                     logonMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, location);
520                                 }
521                             }
522                         }
523                     }
524                 }
525             }
526         } catch (IOException e) {
527             LOGGER.error("Error parsing login form at " + initmethod.getURI());
528         } finally {
529             initmethod.releaseConnection();
530         }
531 
532         return logonMethod;
533     }
534 
535     protected HttpMethod postLogonMethod(HttpClient httpClient, HttpMethod logonMethod, String userName, String password) throws IOException {
536 
537         setAuthFormFields(logonMethod, httpClient, password);
538 
539         // add exchange 2010 PBack cookie in compatibility mode
540         httpClient.getState().addCookie(new Cookie(httpClient.getHostConfiguration().getHost(), "PBack", "0", "/", null, false));
541 
542         logonMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, logonMethod);
543 
544         // test form based authentication
545         checkFormLoginQueryString(logonMethod);
546 
547         // workaround for post logon script redirect
548         if (!isAuthenticated(logonMethod)) {
549             // try to get new method from script based redirection
550             logonMethod = buildLogonMethod(httpClient, logonMethod);
551 
552             if (logonMethod != null) {
553                 if (otpPreAuthFound && otpPreAuthRetries < MAX_OTP_RETRIES) {
554                     // A OTP pre-auth page has been found, it is needed to restart the login process.
555                     // This applies to both the case the user entered a good OTP code (the usual login process
556                     // takes place) and the case the user entered a wrong OTP code (another code will be asked to him).
557                     // The user has up to MAX_OTP_RETRIES chances to input a valid OTP key.
558                     return postLogonMethod(httpClient, logonMethod, userName, password);
559                 }
560 
561                 // if logonMethod is not null, try to follow redirection
562                 logonMethod = DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, logonMethod);
563                 checkFormLoginQueryString(logonMethod);
564                 // also check cookies
565                 if (!isAuthenticated(logonMethod)) {
566                     throwAuthenticationFailed();
567                 }
568             } else {
569                 // authentication failed
570                 throwAuthenticationFailed();
571             }
572         }
573 
574         // check for language selection form
575         if (logonMethod != null && "/owa/languageselection.aspx".equals(logonMethod.getPath())) {
576             // need to submit form
577             logonMethod = submitLanguageSelectionForm(logonMethod);
578         }
579         return logonMethod;
580     }
581 
582     protected void setAuthFormFields(HttpMethod logonMethod, HttpClient httpClient, String password) throws IllegalArgumentException {
583         String userNameInput;
584         if (userNameInputs.size() == 2) {
585             String userid;
586             // multiple username fields, split userid|username on |
587             int pipeIndex = userName.indexOf('|');
588             if (pipeIndex < 0) {
589                 LOGGER.debug("Multiple user fields detected, please use userid|username as user name in client, except when userid is username");
590                 userid = userName;
591             } else {
592                 userid = userName.substring(0, pipeIndex);
593                 userName = userName.substring(pipeIndex + 1);
594                 // adjust credentials
595                 DavGatewayHttpClientFacade.setCredentials(httpClient, userName, password);
596             }
597             ((PostMethod) logonMethod).removeParameter("userid");
598             ((PostMethod) logonMethod).addParameter("userid", userid);
599 
600             userNameInput = "username";
601         } else if (userNameInputs.size() == 1) {
602             // simple username field
603             userNameInput = userNameInputs.get(0);
604         } else {
605             // should not happen
606             userNameInput = "username";
607         }
608         // make sure username and password fields are empty
609         ((PostMethod) logonMethod).removeParameter(userNameInput);
610         if (passwordInput != null) {
611             ((PostMethod) logonMethod).removeParameter(passwordInput);
612         }
613         ((PostMethod) logonMethod).removeParameter("trusted");
614         ((PostMethod) logonMethod).removeParameter("flags");
615 
616         if (passwordInput == null) {
617             // This is a OTP pre-auth page. A different username may be required.
618             otpPreAuthFound = true;
619             otpPreAuthRetries++;
620             ((PostMethod) logonMethod).addParameter(userNameInput, preAuthUsername);
621         } else {
622             otpPreAuthFound = false;
623             otpPreAuthRetries = 0;
624             // This is a regular Exchange login page
625             ((PostMethod) logonMethod).addParameter(userNameInput, userName);
626             ((PostMethod) logonMethod).addParameter(passwordInput, password);
627             ((PostMethod) logonMethod).addParameter("trusted", "4");
628             ((PostMethod) logonMethod).addParameter("flags", "4");
629         }
630     }
631 
632     protected HttpMethod formLogin(HttpClient httpClient, HttpMethod initmethod, String userName, String password) throws IOException {
633         LOGGER.debug("Form based authentication detected");
634 
635         HttpMethod logonMethod = buildLogonMethod(httpClient, initmethod);
636         if (logonMethod == null) {
637             LOGGER.debug("Authentication form not found at " + initmethod.getURI() + ", trying default url");
638             logonMethod = new PostMethod("/owa/auth/owaauth.dll");
639         }
640         logonMethod = postLogonMethod(httpClient, logonMethod, userName, password);
641 
642         return logonMethod;
643     }
644 
645     protected HttpMethod submitLanguageSelectionForm(HttpMethod logonMethod) throws IOException {
646         PostMethod postLanguageFormMethod;
647         // create an instance of HtmlCleaner
648         HtmlCleaner cleaner = new HtmlCleaner();
649 
650         try {
651             TagNode node = cleaner.clean(logonMethod.getResponseBodyAsStream());
652             List forms = node.getElementListByName("form", true);
653             TagNode languageForm;
654             // select form
655             if (forms.size() == 1) {
656                 languageForm = (TagNode) forms.get(0);
657             } else {
658                 throw new IOException("Form not found");
659             }
660             String languageMethodPath = languageForm.getAttributeByName("action");
661 
662             postLanguageFormMethod = new PostMethod(getAbsoluteUri(logonMethod, languageMethodPath));
663 
664             List inputList = languageForm.getElementListByName("input", true);
665             for (Object input : inputList) {
666                 String name = ((TagNode) input).getAttributeByName("name");
667                 String value = ((TagNode) input).getAttributeByName("value");
668                 if (name != null && value != null) {
669                     postLanguageFormMethod.addParameter(name, value);
670                 }
671             }
672             List selectList = languageForm.getElementListByName("select", true);
673             for (Object select : selectList) {
674                 String name = ((TagNode) select).getAttributeByName("name");
675                 List optionList = ((TagNode) select).getElementListByName("option", true);
676                 String value = null;
677                 for (Object option : optionList) {
678                     if (((TagNode) option).getAttributeByName("selected") != null) {
679                         value = ((TagNode) option).getAttributeByName("value");
680                         break;
681                     }
682                 }
683                 if (name != null && value != null) {
684                     postLanguageFormMethod.addParameter(name, value);
685                 }
686             }
687         } catch (IOException e) {
688             String errorMessage = "Error parsing language selection form at " + logonMethod.getURI();
689             LOGGER.error(errorMessage);
690             throw new IOException(errorMessage);
691         } finally {
692             logonMethod.releaseConnection();
693         }
694 
695         return DavGatewayHttpClientFacade.executeFollowRedirects(httpClient, postLanguageFormMethod);
696     }
697 
698     /**
699      * Look for session cookies.
700      *
701      * @return true if session cookies are available
702      */
703     protected boolean isAuthenticated(HttpMethod method) {
704         boolean authenticated = false;
705         if (method.getStatusCode() == HttpStatus.SC_OK
706                 && "/ews/services.wsdl".equalsIgnoreCase(method.getPath())) {
707             // direct EWS access returned wsdl
708             authenticated = true;
709         } else {
710             // check cookies
711             for (Cookie cookie : httpClient.getState().getCookies()) {
712                 // Exchange 2003 cookies
713                 if (cookie.getName().startsWith("cadata") || "sessionid".equals(cookie.getName())
714                         // Exchange 2007 cookie
715                         || "UserContext".equals(cookie.getName())
716                         // Direct EWS access
717                         || "exchangecookie".equals(cookie.getName())
718                         ) {
719                     authenticated = true;
720                     break;
721                 }
722             }
723         }
724         return authenticated;
725     }
726 
727     protected void checkFormLoginQueryString(HttpMethod logonMethod) throws DavMailAuthenticationException {
728         String queryString = logonMethod.getQueryString();
729         if (queryString != null && (queryString.contains("reason=2") || queryString.contains("reason=4"))) {
730             logonMethod.releaseConnection();
731             throwAuthenticationFailed();
732         }
733     }
734 
735     protected void throwAuthenticationFailed() throws DavMailAuthenticationException {
736         if (this.userName != null && this.userName.contains("\\")) {
737             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED");
738         } else {
739             throw new DavMailAuthenticationException("EXCEPTION_AUTHENTICATION_FAILED_RETRY");
740         }
741     }
742 
743     protected abstract void buildSessionInfo(HttpMethod method) throws DavMailException;
744 
745     /**
746      * Create message in specified folder.
747      * Will overwrite an existing message with same subject in the same folder
748      *
749      * @param folderPath  Exchange folder path
750      * @param messageName message name
751      * @param properties  message properties (flags)
752      * @param mimeMessage MIME message
753      * @throws IOException when unable to create message
754      */
755     public abstract void createMessage(String folderPath, String messageName, HashMap<String, String> properties, MimeMessage mimeMessage) throws IOException;
756 
757     /**
758      * Update given properties on message.
759      *
760      * @param message    Exchange message
761      * @param properties Webdav properties map
762      * @throws IOException on error
763      */
764     public abstract void updateMessage(Message message, Map<String, String> properties) throws IOException;
765 
766 
767     /**
768      * Delete Exchange message.
769      *
770      * @param message Exchange message
771      * @throws IOException on error
772      */
773     public abstract void deleteMessage(Message message) throws IOException;
774 
775     /**
776      * Get raw MIME message content
777      *
778      * @param message Exchange message
779      * @return message body
780      * @throws IOException on error
781      */
782     protected abstract byte[] getContent(Message message) throws IOException;
783 
784     protected static final Set<String> POP_MESSAGE_ATTRIBUTES = new HashSet<String>();
785 
786     static {
787         POP_MESSAGE_ATTRIBUTES.add("uid");
788         POP_MESSAGE_ATTRIBUTES.add("imapUid");
789         POP_MESSAGE_ATTRIBUTES.add("messageSize");
790     }
791 
792     /**
793      * Return folder message list with id and size only (for POP3 listener).
794      *
795      * @param folderName Exchange folder name
796      * @return folder message list
797      * @throws IOException on error
798      */
799     public MessageList getAllMessageUidAndSize(String folderName) throws IOException {
800         return searchMessages(folderName, POP_MESSAGE_ATTRIBUTES, null);
801     }
802 
803     protected static final Set<String> IMAP_MESSAGE_ATTRIBUTES = new HashSet<String>();
804 
805     static {
806         IMAP_MESSAGE_ATTRIBUTES.add("permanenturl");
807         IMAP_MESSAGE_ATTRIBUTES.add("urlcompname");
808         IMAP_MESSAGE_ATTRIBUTES.add("uid");
809         IMAP_MESSAGE_ATTRIBUTES.add("messageSize");
810         IMAP_MESSAGE_ATTRIBUTES.add("imapUid");
811         IMAP_MESSAGE_ATTRIBUTES.add("junk");
812         IMAP_MESSAGE_ATTRIBUTES.add("flagStatus");
813         IMAP_MESSAGE_ATTRIBUTES.add("messageFlags");
814         IMAP_MESSAGE_ATTRIBUTES.add("lastVerbExecuted");
815         IMAP_MESSAGE_ATTRIBUTES.add("read");
816         IMAP_MESSAGE_ATTRIBUTES.add("deleted");
817         IMAP_MESSAGE_ATTRIBUTES.add("date");
818         IMAP_MESSAGE_ATTRIBUTES.add("lastmodified");
819         // OSX IMAP requests content-class
820         IMAP_MESSAGE_ATTRIBUTES.add("contentclass");
821         IMAP_MESSAGE_ATTRIBUTES.add("keywords");
822     }
823 
824     protected static final Set<String> UID_MESSAGE_ATTRIBUTES = new HashSet<String>();
825 
826     static {
827         UID_MESSAGE_ATTRIBUTES.add("uid");
828     }
829 
830     /**
831      * Get all folder messages.
832      *
833      * @param folderPath Exchange folder name
834      * @return message list
835      * @throws IOException on error
836      */
837     public MessageList searchMessages(String folderPath) throws IOException {
838         return searchMessages(folderPath, IMAP_MESSAGE_ATTRIBUTES, null);
839     }
840 
841     /**
842      * Search folder for messages matching conditions, with attributes needed by IMAP listener.
843      *
844      * @param folderName Exchange folder name
845      * @param condition  search filter
846      * @return message list
847      * @throws IOException on error
848      */
849     public MessageList searchMessages(String folderName, Condition condition) throws IOException {
850         return searchMessages(folderName, IMAP_MESSAGE_ATTRIBUTES, condition);
851     }
852 
853     /**
854      * Search folder for messages matching conditions, with given attributes.
855      *
856      * @param folderName Exchange folder name
857      * @param attributes requested Webdav attributes
858      * @param condition  search filter
859      * @return message list
860      * @throws IOException on error
861      */
862     public abstract MessageList searchMessages(String folderName, Set<String> attributes, Condition condition) throws IOException;
863 
864     /**
865      * Get server version (Exchange2003, Exchange2007 or Exchange2010)
866      *
867      * @return server version
868      */
869     public String getServerVersion() {
870         return serverVersion;
871     }
872 
873     @SuppressWarnings({"JavaDoc"})
874     public enum Operator {
875         Or, And, Not, IsEqualTo,
876         IsGreaterThan, IsGreaterThanOrEqualTo,
877         IsLessThan, IsLessThanOrEqualTo,
878         IsNull, IsTrue, IsFalse,
879         Like, StartsWith, Contains
880     }
881 
882     /**
883      * Exchange search filter.
884      */
885     public static interface Condition {
886         /**
887          * Append condition to buffer.
888          *
889          * @param buffer search filter buffer
890          */
891         void appendTo(StringBuilder buffer);
892 
893         /**
894          * True if condition is empty.
895          *
896          * @return true if condition is empty
897          */
898         boolean isEmpty();
899 
900         /**
901          * Test if the contact matches current condition.
902          *
903          * @param contact Exchange Contact
904          * @return true if contact matches condition
905          */
906         boolean isMatch(ExchangeSession.Contact contact);
907     }
908 
909     /**
910      * Attribute condition.
911      */
912     public abstract static class AttributeCondition implements Condition {
913         protected final String attributeName;
914         protected final Operator operator;
915         protected final String value;
916 
917         protected AttributeCondition(String attributeName, Operator operator, String value) {
918             this.attributeName = attributeName;
919             this.operator = operator;
920             this.value = value;
921         }
922 
923         public boolean isEmpty() {
924             return false;
925         }
926 
927         /**
928          * Get attribute name.
929          *
930          * @return attribute name
931          */
932         public String getAttributeName() {
933             return attributeName;
934         }
935 
936         /**
937          * Condition value.
938          *
939          * @return value
940          */
941         public String getValue() {
942             return value;
943         }
944 
945     }
946 
947     /**
948      * Multiple condition.
949      */
950     public abstract static class MultiCondition implements Condition {
951         protected final Operator operator;
952         protected final List<Condition> conditions;
953 
954         protected MultiCondition(Operator operator, Condition... conditions) {
955             this.operator = operator;
956             this.conditions = new ArrayList<Condition>();
957             for (Condition condition : conditions) {
958                 if (condition != null) {
959                     this.conditions.add(condition);
960                 }
961             }
962         }
963 
964         /**
965          * Conditions list.
966          *
967          * @return conditions
968          */
969         public List<Condition> getConditions() {
970             return conditions;
971         }
972 
973         /**
974          * Condition operator.
975          *
976          * @return operator
977          */
978         public Operator getOperator() {
979             return operator;
980         }
981 
982         /**
983          * Add a new condition.
984          *
985          * @param condition single condition
986          */
987         public void add(Condition condition) {
988             if (condition != null) {
989                 conditions.add(condition);
990             }
991         }
992 
993         public boolean isEmpty() {
994             boolean isEmpty = true;
995             for (Condition condition : conditions) {
996                 if (!condition.isEmpty()) {
997                     isEmpty = false;
998                     break;
999                 }
1000             }
1001             return isEmpty;
1002         }
1003 
1004         public boolean isMatch(ExchangeSession.Contact contact) {
1005             if (operator == Operator.And) {
1006                 for (Condition condition : conditions) {
1007                     if (!condition.isMatch(contact)) {
1008                         return false;
1009                     }
1010                 }
1011                 return true;
1012             } else if (operator == Operator.Or) {
1013                 for (Condition condition : conditions) {
1014                     if (condition.isMatch(contact)) {
1015                         return true;
1016                     }
1017                 }
1018                 return false;
1019             } else {
1020                 return false;
1021             }
1022         }
1023 
1024     }
1025 
1026     /**
1027      * Not condition.
1028      */
1029     public abstract static class NotCondition implements Condition {
1030         protected final Condition condition;
1031 
1032         protected NotCondition(Condition condition) {
1033             this.condition = condition;
1034         }
1035 
1036         public boolean isEmpty() {
1037             return condition.isEmpty();
1038         }
1039 
1040         public boolean isMatch(ExchangeSession.Contact contact) {
1041             return !condition.isMatch(contact);
1042         }
1043     }
1044 
1045     /**
1046      * Single search filter condition.
1047      */
1048     public abstract static class MonoCondition implements Condition {
1049         protected final String attributeName;
1050         protected final Operator operator;
1051 
1052         protected MonoCondition(String attributeName, Operator operator) {
1053             this.attributeName = attributeName;
1054             this.operator = operator;
1055         }
1056 
1057         public boolean isEmpty() {
1058             return false;
1059         }
1060 
1061         public boolean isMatch(ExchangeSession.Contact contact) {
1062             String actualValue = contact.get(attributeName);
1063             return (operator == Operator.IsNull && actualValue == null) ||
1064                     (operator == Operator.IsFalse && "false".equals(actualValue)) ||
1065                     (operator == Operator.IsTrue && "true".equals(actualValue));
1066         }
1067     }
1068 
1069     /**
1070      * And search filter.
1071      *
1072      * @param condition search conditions
1073      * @return condition
1074      */
1075     public abstract MultiCondition and(Condition... condition);
1076 
1077     /**
1078      * Or search filter.
1079      *
1080      * @param condition search conditions
1081      * @return condition
1082      */
1083     public abstract MultiCondition or(Condition... condition);
1084 
1085     /**
1086      * Not search filter.
1087      *
1088      * @param condition search condition
1089      * @return condition
1090      */
1091     public abstract Condition not(Condition condition);
1092 
1093     /**
1094      * Equals condition.
1095      *
1096      * @param attributeName logical Exchange attribute name
1097      * @param value         attribute value
1098      * @return condition
1099      */
1100     public abstract Condition isEqualTo(String attributeName, String value);
1101 
1102     /**
1103      * Equals condition.
1104      *
1105      * @param attributeName logical Exchange attribute name
1106      * @param value         attribute value
1107      * @return condition
1108      */
1109     public abstract Condition isEqualTo(String attributeName, int value);
1110 
1111     /**
1112      * MIME header equals condition.
1113      *
1114      * @param headerName MIME header name
1115      * @param value      attribute value
1116      * @return condition
1117      */
1118     public abstract Condition headerIsEqualTo(String headerName, String value);
1119 
1120     /**
1121      * Greater than or equals condition.
1122      *
1123      * @param attributeName logical Exchange attribute name
1124      * @param value         attribute value
1125      * @return condition
1126      */
1127     public abstract Condition gte(String attributeName, String value);
1128 
1129     /**
1130      * Greater than condition.
1131      *
1132      * @param attributeName logical Exchange attribute name
1133      * @param value         attribute value
1134      * @return condition
1135      */
1136     public abstract Condition gt(String attributeName, String value);
1137 
1138     /**
1139      * Lower than condition.
1140      *
1141      * @param attributeName logical Exchange attribute name
1142      * @param value         attribute value
1143      * @return condition
1144      */
1145     public abstract Condition lt(String attributeName, String value);
1146 
1147     /**
1148      * Lower than or equals condition.
1149      *
1150      * @param attributeName logical Exchange attribute name
1151      * @param value         attribute value
1152      * @return condition
1153      */
1154     @SuppressWarnings({"UnusedDeclaration"})
1155     public abstract Condition lte(String attributeName, String value);
1156 
1157     /**
1158      * Contains condition.
1159      *
1160      * @param attributeName logical Exchange attribute name
1161      * @param value         attribute value
1162      * @return condition
1163      */
1164     public abstract Condition contains(String attributeName, String value);
1165 
1166     /**
1167      * Starts with condition.
1168      *
1169      * @param attributeName logical Exchange attribute name
1170      * @param value         attribute value
1171      * @return condition
1172      */
1173     public abstract Condition startsWith(String attributeName, String value);
1174 
1175     /**
1176      * Is null condition.
1177      *
1178      * @param attributeName logical Exchange attribute name
1179      * @return condition
1180      */
1181     public abstract Condition isNull(String attributeName);
1182 
1183     /**
1184      * Is true condition.
1185      *
1186      * @param attributeName logical Exchange attribute name
1187      * @return condition
1188      */
1189     public abstract Condition isTrue(String attributeName);
1190 
1191     /**
1192      * Is false condition.
1193      *
1194      * @param attributeName logical Exchange attribute name
1195      * @return condition
1196      */
1197     public abstract Condition isFalse(String attributeName);
1198 
1199     /**
1200      * Search mail and generic folders under given folder.
1201      * Exclude calendar and contacts folders
1202      *
1203      * @param folderName Exchange folder name
1204      * @param recursive  deep search if true
1205      * @return list of folders
1206      * @throws IOException on error
1207      */
1208     public List<Folder> getSubFolders(String folderName, boolean recursive) throws IOException {
1209         Condition folderCondition = null;
1210         if (!Settings.getBooleanProperty("davmail.imapIncludeSpecialFolders", false)) {
1211             folderCondition = or(isEqualTo("folderclass", "IPF.Note"), isNull("folderclass"));
1212         }
1213         List<Folder> results = getSubFolders(folderName, folderCondition,
1214                 recursive);
1215         // need to include base folder in recursive search, except on root
1216         if (recursive && folderName.length() > 0) {
1217             results.add(getFolder(folderName));
1218         }
1219 
1220         return results;
1221     }
1222 
1223     /**
1224      * Search calendar folders under given folder.
1225      *
1226      * @param folderName Exchange folder name
1227      * @param recursive  deep search if true
1228      * @return list of folders
1229      * @throws IOException on error
1230      */
1231     public List<Folder> getSubCalendarFolders(String folderName, boolean recursive) throws IOException {
1232         return getSubFolders(folderName, isEqualTo("folderclass", "IPF.Appointment"), recursive);
1233     }
1234 
1235     /**
1236      * Search folders under given folder matching filter.
1237      *
1238      * @param folderName Exchange folder name
1239      * @param condition  search filter
1240      * @param recursive  deep search if true
1241      * @return list of folders
1242      * @throws IOException on error
1243      */
1244     public abstract List<Folder> getSubFolders(String folderName, Condition condition, boolean recursive) throws IOException;
1245 
1246     /**
1247      * Delete oldest messages in trash.
1248      * keepDelay is the number of days to keep messages in trash before delete
1249      *
1250      * @throws IOException when unable to purge messages
1251      */
1252     public void purgeOldestTrashAndSentMessages() throws IOException {
1253         int keepDelay = Settings.getIntProperty("davmail.keepDelay");
1254         if (keepDelay != 0) {
1255             purgeOldestFolderMessages(TRASH, keepDelay);
1256         }
1257         // this is a new feature, default is : do nothing
1258         int sentKeepDelay = Settings.getIntProperty("davmail.sentKeepDelay");
1259         if (sentKeepDelay != 0) {
1260             purgeOldestFolderMessages(SENT, sentKeepDelay);
1261         }
1262     }
1263 
1264     protected void purgeOldestFolderMessages(String folderPath, int keepDelay) throws IOException {
1265         Calendar cal = Calendar.getInstance();
1266         cal.add(Calendar.DAY_OF_MONTH, -keepDelay);
1267         LOGGER.debug("Delete messages in " + folderPath + " not modified since " + cal.getTime());
1268 
1269         MessageList messages = searchMessages(folderPath, UID_MESSAGE_ATTRIBUTES,
1270                 lt("lastmodified", formatSearchDate(cal.getTime())));
1271 
1272         for (Message message : messages) {
1273             message.delete();
1274         }
1275     }
1276 
1277     protected void convertResentHeader(MimeMessage mimeMessage, String headerName) throws MessagingException {
1278         String[] resentHeader = mimeMessage.getHeader("Resent-" + headerName);
1279         if (resentHeader != null) {
1280             mimeMessage.removeHeader("Resent-" + headerName);
1281             mimeMessage.removeHeader(headerName);
1282             for (String value : resentHeader) {
1283                 mimeMessage.addHeader(headerName, value);
1284             }
1285         }
1286     }
1287 
1288     protected String lastSentMessageId;
1289 
1290     /**
1291      * Send message in reader to recipients.
1292      * Detect visible recipients in message body to determine bcc recipients
1293      *
1294      * @param rcptToRecipients recipients list
1295      * @param mimeMessage      mime message
1296      * @throws IOException        on error
1297      * @throws MessagingException on error
1298      */
1299     public void sendMessage(List<String> rcptToRecipients, MimeMessage mimeMessage) throws IOException, MessagingException {
1300         // detect duplicate send command
1301         String messageId = mimeMessage.getMessageID();
1302         if (lastSentMessageId != null && lastSentMessageId.equals(messageId)) {
1303             LOGGER.debug("Dropping message id " + messageId + ": already sent");
1304             return;
1305         }
1306         lastSentMessageId = messageId;
1307 
1308         convertResentHeader(mimeMessage, "From");
1309         convertResentHeader(mimeMessage, "To");
1310         convertResentHeader(mimeMessage, "Cc");
1311         convertResentHeader(mimeMessage, "Bcc");
1312         convertResentHeader(mimeMessage, "Message-Id");
1313 
1314         // do not allow send as another user on Exchange 2003
1315         if ("Exchange2003".equals(serverVersion) || Settings.getBooleanProperty("davmail.smtpStripFrom", false)) {
1316             mimeMessage.removeHeader("From");
1317         }
1318 
1319         // remove visible recipients from list
1320         Set<String> visibleRecipients = new HashSet<String>();
1321         List<InternetAddress> recipients = getAllRecipients(mimeMessage);
1322         for (InternetAddress address : recipients) {
1323             visibleRecipients.add((address.getAddress().toLowerCase()));
1324         }
1325         for (String recipient : rcptToRecipients) {
1326             if (!visibleRecipients.contains(recipient.toLowerCase())) {
1327                 mimeMessage.addRecipient(javax.mail.Message.RecipientType.BCC, new InternetAddress(recipient));
1328             }
1329         }
1330         sendMessage(mimeMessage);
1331 
1332     }
1333 
1334     static final String[] RECIPIENT_HEADERS = {"to", "cc", "bcc"};
1335 
1336     protected List<InternetAddress> getAllRecipients(MimeMessage mimeMessage) throws MessagingException {
1337         List<InternetAddress> recipientList = new ArrayList<InternetAddress>();
1338         for (String recipientHeader : RECIPIENT_HEADERS) {
1339             final String recipientHeaderValue = mimeMessage.getHeader(recipientHeader, ",");
1340             if (recipientHeaderValue != null) {
1341                 // parse headers in non strict mode
1342                 recipientList.addAll(Arrays.asList(InternetAddress.parseHeader(recipientHeaderValue, false)));
1343             }
1344 
1345         }
1346         return recipientList;
1347     }
1348 
1349     /**
1350      * Send Mime message.
1351      *
1352      * @param mimeMessage MIME message
1353      * @throws IOException        on error
1354      * @throws MessagingException on error
1355      */
1356     public abstract void sendMessage(MimeMessage mimeMessage) throws IOException, MessagingException;
1357 
1358     /**
1359      * Get folder object.
1360      * Folder name can be logical names INBOX, Drafts, Trash or calendar,
1361      * or a path relative to user base folder or absolute path.
1362      *
1363      * @param folderPath folder path
1364      * @return Folder object
1365      * @throws IOException on error
1366      */
1367     public ExchangeSession.Folder getFolder(String folderPath) throws IOException {
1368         Folder folder = internalGetFolder(folderPath);
1369         if (isMainCalendar(folderPath)) {
1370             Folder taskFolder = internalGetFolder(TASKS);
1371             folder.ctag += taskFolder.ctag;
1372         }
1373         return folder;
1374     }
1375 
1376     protected abstract Folder internalGetFolder(String folderName) throws IOException;
1377 
1378     /**
1379      * Check folder ctag and reload messages as needed.
1380      *
1381      * @param currentFolder current folder
1382      * @return true if folder changed
1383      * @throws IOException on error
1384      */
1385     public boolean refreshFolder(Folder currentFolder) throws IOException {
1386         Folder newFolder = getFolder(currentFolder.folderPath);
1387         if (currentFolder.ctag == null || !currentFolder.ctag.equals(newFolder.ctag)
1388                 // ctag stamp is limited to second, check message count
1389                 || !(currentFolder.count == newFolder.count)
1390                 ) {
1391             if (LOGGER.isDebugEnabled()) {
1392                 LOGGER.debug("Contenttag or count changed on " + currentFolder.folderPath +
1393                         " ctag: " + currentFolder.ctag + " => " + newFolder.ctag +
1394                         " count: " + currentFolder.count + " => " + newFolder.count
1395                         + ", reloading messages");
1396             }
1397             currentFolder.hasChildren = newFolder.hasChildren;
1398             currentFolder.noInferiors = newFolder.noInferiors;
1399             currentFolder.unreadCount = newFolder.unreadCount;
1400             currentFolder.ctag = newFolder.ctag;
1401             currentFolder.etag = newFolder.etag;
1402             if (newFolder.uidNext > currentFolder.uidNext) {
1403                 currentFolder.uidNext = newFolder.uidNext;
1404             }
1405             currentFolder.loadMessages();
1406             return true;
1407         } else {
1408             return false;
1409         }
1410     }
1411 
1412     /**
1413      * Create Exchange message folder.
1414      *
1415      * @param folderName logical folder name
1416      * @return status
1417      * @throws IOException on error
1418      */
1419     public int createMessageFolder(String folderName) throws IOException {
1420         return createFolder(folderName, "IPF.Note", null);
1421     }
1422 
1423     /**
1424      * Create Exchange calendar folder.
1425      *
1426      * @param folderName logical folder name
1427      * @param properties folder properties
1428      * @return status
1429      * @throws IOException on error
1430      */
1431     public int createCalendarFolder(String folderName, Map<String, String> properties) throws IOException {
1432         return createFolder(folderName, "IPF.Appointment", properties);
1433     }
1434 
1435     /**
1436      * Create Exchange contact folder.
1437      *
1438      * @param folderName logical folder name
1439      * @param properties folder properties
1440      * @return status
1441      * @throws IOException on error
1442      */
1443     public int createContactFolder(String folderName, Map<String, String> properties) throws IOException {
1444         return createFolder(folderName, "IPF.Contact", properties);
1445     }
1446 
1447     /**
1448      * Create Exchange folder with given folder class.
1449      *
1450      * @param folderName  logical folder name
1451      * @param folderClass folder class
1452      * @param properties  folder properties
1453      * @return status
1454      * @throws IOException on error
1455      */
1456     public abstract int createFolder(String folderName, String folderClass, Map<String, String> properties) throws IOException;
1457 
1458     /**
1459      * Update Exchange folder properties.
1460      *
1461      * @param folderName logical folder name
1462      * @param properties folder properties
1463      * @return status
1464      * @throws IOException on error
1465      */
1466     public abstract int updateFolder(String folderName, Map<String, String> properties) throws IOException;
1467 
1468     /**
1469      * Delete Exchange folder.
1470      *
1471      * @param folderName logical folder name
1472      * @throws IOException on error
1473      */
1474     public abstract void deleteFolder(String folderName) throws IOException;
1475 
1476     /**
1477      * Copy message to target folder
1478      *
1479      * @param message      Exchange message
1480      * @param targetFolder target folder
1481      * @throws IOException on error
1482      */
1483     public abstract void copyMessage(Message message, String targetFolder) throws IOException;
1484 
1485     public void copyMessages(List<Message> messages, String targetFolder) throws IOException {
1486         for (Message message: messages) {
1487             copyMessage(message, targetFolder);
1488         }
1489     }
1490 
1491 
1492     /**
1493      * Move message to target folder
1494      *
1495      * @param message      Exchange message
1496      * @param targetFolder target folder
1497      * @throws IOException on error
1498      */
1499     public abstract void moveMessage(Message message, String targetFolder) throws IOException;
1500 
1501     public void moveMessages(List<Message> messages, String targetFolder) throws IOException {
1502         for (Message message: messages) {
1503             moveMessage(message, targetFolder);
1504         }
1505     }
1506 
1507     /**
1508      * Move folder to target name.
1509      *
1510      * @param folderName current folder name/path
1511      * @param targetName target folder name/path
1512      * @throws IOException on error
1513      */
1514     public abstract void moveFolder(String folderName, String targetName) throws IOException;
1515 
1516     /**
1517      * Move item from source path to target path.
1518      *
1519      * @param sourcePath item source path
1520      * @param targetPath item target path
1521      * @throws IOException on error
1522      */
1523     public abstract void moveItem(String sourcePath, String targetPath) throws IOException;
1524 
1525     protected abstract void moveToTrash(Message message) throws IOException;
1526 
1527     /**
1528      * Convert keyword value to IMAP flag.
1529      *
1530      * @param value keyword value
1531      * @return IMAP flag
1532      */
1533     public String convertKeywordToFlag(String value) {
1534         // first test for keyword in settings
1535         Properties flagSettings = Settings.getSubProperties("davmail.imapFlags");
1536         Enumeration flagSettingsEnum = flagSettings.propertyNames();
1537         while (flagSettingsEnum.hasMoreElements()) {
1538             String key = (String) flagSettingsEnum.nextElement();
1539             if (value.equalsIgnoreCase(flagSettings.getProperty(key))) {
1540                 return key;
1541             }
1542         }
1543 
1544         ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags");
1545         Enumeration<String> flagBundleEnum = flagBundle.getKeys();
1546         while (flagBundleEnum.hasMoreElements()) {
1547             String key = flagBundleEnum.nextElement();
1548             if (value.equalsIgnoreCase(flagBundle.getString(key))) {
1549                 return key;
1550             }
1551         }
1552 
1553         // fall back to raw value
1554         return value;
1555     }
1556 
1557     /**
1558      * Convert IMAP flag to keyword value.
1559      *
1560      * @param value IMAP flag
1561      * @return keyword value
1562      */
1563     public String convertFlagToKeyword(String value) {
1564         // first test for flag in settings
1565         Properties flagSettings = Settings.getSubProperties("davmail.imapFlags");
1566         String flagValue = flagSettings.getProperty(value);
1567         if (flagValue != null) {
1568             return flagValue;
1569         }
1570 
1571         // fall back to predefined flags
1572         ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags");
1573         try {
1574             return flagBundle.getString(value);
1575         } catch (MissingResourceException e) {
1576             // ignore
1577         }
1578 
1579         // fall back to raw value
1580         return value;
1581     }
1582 
1583     /**
1584      * Exchange folder with IMAP properties
1585      */
1586     public class Folder {
1587         /**
1588          * Logical (IMAP) folder path.
1589          */
1590         public String folderPath;
1591 
1592         /**
1593          * Display Name.
1594          */
1595         public String displayName;
1596         /**
1597          * Folder class (PR_CONTAINER_CLASS).
1598          */
1599         public String folderClass;
1600         /**
1601          * Folder message count.
1602          */
1603         public int count;
1604         /**
1605          * Folder unread message count.
1606          */
1607         public int unreadCount;
1608         /**
1609          * true if folder has subfolders (DAV:hassubs).
1610          */
1611         public boolean hasChildren;
1612         /**
1613          * true if folder has no subfolders (DAV:nosubs).
1614          */
1615         public boolean noInferiors;
1616         /**
1617          * Folder content tag (to detect folder content changes).
1618          */
1619         public String ctag;
1620         /**
1621          * Folder etag (to detect folder object changes).
1622          */
1623         public String etag;
1624         /**
1625          * Next IMAP uid
1626          */
1627         public long uidNext;
1628         /**
1629          * recent count
1630          */
1631         public int recent;
1632 
1633         /**
1634          * Folder message list, empty before loadMessages call.
1635          */
1636         public ExchangeSession.MessageList messages;
1637         /**
1638          * Permanent uid (PR_SEARCH_KEY) to IMAP UID map.
1639          */
1640         private final HashMap<String, Long> permanentUrlToImapUidMap = new HashMap<String, Long>();
1641 
1642         /**
1643          * Get IMAP folder flags.
1644          *
1645          * @return folder flags in IMAP format
1646          */
1647         public String getFlags() {
1648             if (noInferiors) {
1649                 return "\\NoInferiors";
1650             } else if (hasChildren) {
1651                 return "\\HasChildren";
1652             } else {
1653                 return "\\HasNoChildren";
1654             }
1655         }
1656 
1657         /**
1658          * Load folder messages.
1659          *
1660          * @throws IOException on error
1661          */
1662         public void loadMessages() throws IOException {
1663             messages = ExchangeSession.this.searchMessages(folderPath, null);
1664             fixUids(messages);
1665             recent = 0;
1666             for (Message message : messages) {
1667                 if (message.recent) {
1668                     recent++;
1669                 }
1670             }
1671             long computedUidNext = 1;
1672             if (!messages.isEmpty()) {
1673                 computedUidNext = messages.get(messages.size() - 1).getImapUid() + 1;
1674             }
1675             if (computedUidNext > uidNext) {
1676                 uidNext = computedUidNext;
1677             }
1678         }
1679 
1680         /**
1681          * Search messages in folder matching query.
1682          *
1683          * @param condition search query
1684          * @return message list
1685          * @throws IOException on error
1686          */
1687         public MessageList searchMessages(Condition condition) throws IOException {
1688             MessageList localMessages = ExchangeSession.this.searchMessages(folderPath, condition);
1689             fixUids(localMessages);
1690             return localMessages;
1691         }
1692 
1693         /**
1694          * Restore previous uids changed by a PROPPATCH (flag change).
1695          *
1696          * @param messages message list
1697          */
1698         protected void fixUids(MessageList messages) {
1699             boolean sortNeeded = false;
1700             for (Message message : messages) {
1701                 if (permanentUrlToImapUidMap.containsKey(message.getPermanentId())) {
1702                     long previousUid = permanentUrlToImapUidMap.get(message.getPermanentId());
1703                     if (message.getImapUid() != previousUid) {
1704                         LOGGER.debug("Restoring IMAP uid " + message.getImapUid() + " -> " + previousUid + " for message " + message.getPermanentId());
1705                         message.setImapUid(previousUid);
1706                         sortNeeded = true;
1707                     }
1708                 } else {
1709                     // add message to uid map
1710                     permanentUrlToImapUidMap.put(message.getPermanentId(), message.getImapUid());
1711                 }
1712             }
1713             if (sortNeeded) {
1714                 Collections.sort(messages);
1715             }
1716         }
1717 
1718         /**
1719          * Folder message count.
1720          *
1721          * @return message count
1722          */
1723         public int count() {
1724             if (messages == null) {
1725                 return count;
1726             } else {
1727                 return messages.size();
1728             }
1729         }
1730 
1731         /**
1732          * Compute IMAP uidnext.
1733          *
1734          * @return max(messageuids)+1
1735          */
1736         public long getUidNext() {
1737             return uidNext;
1738         }
1739 
1740         /**
1741          * Get message at index.
1742          *
1743          * @param index message index
1744          * @return message
1745          */
1746         public Message get(int index) {
1747             return messages.get(index);
1748         }
1749 
1750         /**
1751          * Get current folder messages imap uids and flags
1752          *
1753          * @return imap uid list
1754          */
1755         public TreeMap<Long, String> getImapFlagMap() {
1756             TreeMap<Long, String> imapFlagMap = new TreeMap<Long, String>();
1757             for (ExchangeSession.Message message : messages) {
1758                 imapFlagMap.put(message.getImapUid(), message.getImapFlags());
1759             }
1760             return imapFlagMap;
1761         }
1762 
1763         /**
1764          * Calendar folder flag.
1765          *
1766          * @return true if this is a calendar folder
1767          */
1768         public boolean isCalendar() {
1769             return "IPF.Appointment".equals(folderClass);
1770         }
1771 
1772         /**
1773          * Contact folder flag.
1774          *
1775          * @return true if this is a calendar folder
1776          */
1777         public boolean isContact() {
1778             return "IPF.Contact".equals(folderClass);
1779         }
1780 
1781         /**
1782          * Task folder flag.
1783          *
1784          * @return true if this is a task folder
1785          */
1786         public boolean isTask() {
1787             return "IPF.Task".equals(folderClass);
1788         }
1789 
1790         /**
1791          * drop cached message
1792          */
1793         public void clearCache() {
1794             messages.cachedMimeBody = null;
1795             messages.cachedMimeMessage = null;
1796             messages.cachedMessageImapUid = 0;
1797         }
1798     }
1799 
1800     /**
1801      * Exchange message.
1802      */
1803     public abstract class Message implements Comparable<Message> {
1804         /**
1805          * enclosing message list
1806          */
1807         public MessageList messageList;
1808         /**
1809          * Message url.
1810          */
1811         public String messageUrl;
1812         /**
1813          * Message permanent url (does not change on message move).
1814          */
1815         public String permanentUrl;
1816         /**
1817          * Message uid.
1818          */
1819         public String uid;
1820         /**
1821          * Message content class.
1822          */
1823         public String contentClass;
1824         /**
1825          * Message keywords (categories).
1826          */
1827         public String keywords;
1828         /**
1829          * Message IMAP uid, unique in folder (x0e230003).
1830          */
1831         public long imapUid;
1832         /**
1833          * MAPI message size.
1834          */
1835         public int size;
1836         /**
1837          * Message date (urn:schemas:mailheader:date).
1838          */
1839         public String date;
1840 
1841         /**
1842          * Message flag: read.
1843          */
1844         public boolean read;
1845         /**
1846          * Message flag: deleted.
1847          */
1848         public boolean deleted;
1849         /**
1850          * Message flag: junk.
1851          */
1852         public boolean junk;
1853         /**
1854          * Message flag: flagged.
1855          */
1856         public boolean flagged;
1857         /**
1858          * Message flag: recent.
1859          */
1860         public boolean recent;
1861         /**
1862          * Message flag: draft.
1863          */
1864         public boolean draft;
1865         /**
1866          * Message flag: answered.
1867          */
1868         public boolean answered;
1869         /**
1870          * Message flag: fowarded.
1871          */
1872         public boolean forwarded;
1873 
1874         /**
1875          * Unparsed message content.
1876          */
1877         protected SharedByteArrayInputStream mimeBody;
1878 
1879         /**
1880          * Message content parsed in a MIME message.
1881          */
1882         protected MimeMessage mimeMessage;
1883 
1884         /**
1885          * Get permanent message id.
1886          * permanentUrl over WebDav or IitemId over EWS
1887          *
1888          * @return permanent id
1889          */
1890         public abstract String getPermanentId();
1891 
1892         /**
1893          * IMAP uid , unique in folder (x0e230003)
1894          *
1895          * @return IMAP uid
1896          */
1897         public long getImapUid() {
1898             return imapUid;
1899         }
1900 
1901         /**
1902          * Set IMAP uid.
1903          *
1904          * @param imapUid new uid
1905          */
1906         public void setImapUid(long imapUid) {
1907             this.imapUid = imapUid;
1908         }
1909 
1910         /**
1911          * Exchange uid.
1912          *
1913          * @return uid
1914          */
1915         public String getUid() {
1916             return uid;
1917         }
1918 
1919         /**
1920          * Return message flags in IMAP format.
1921          *
1922          * @return IMAP flags
1923          */
1924         public String getImapFlags() {
1925             StringBuilder buffer = new StringBuilder();
1926             if (read) {
1927                 buffer.append("\\Seen ");
1928             }
1929             if (deleted) {
1930                 buffer.append("\\Deleted ");
1931             }
1932             if (recent) {
1933                 buffer.append("\\Recent ");
1934             }
1935             if (flagged) {
1936                 buffer.append("\\Flagged ");
1937             }
1938             if (junk) {
1939                 buffer.append("Junk ");
1940             }
1941             if (draft) {
1942                 buffer.append("\\Draft ");
1943             }
1944             if (answered) {
1945                 buffer.append("\\Answered ");
1946             }
1947             if (forwarded) {
1948                 buffer.append("$Forwarded ");
1949             }
1950             if (keywords != null) {
1951                 for (String keyword : keywords.split(",")) {
1952                     buffer.append(convertKeywordToFlag(keyword)).append(" ");
1953                 }
1954             }
1955             return buffer.toString().trim();
1956         }
1957 
1958         /**
1959          * Load message content in a Mime message
1960          *
1961          * @throws IOException        on error
1962          * @throws MessagingException on error
1963          */
1964         public void loadMimeMessage() throws IOException, MessagingException {
1965             if (mimeMessage == null) {
1966                 // try to get message content from cache
1967                 if (this.imapUid == messageList.cachedMessageImapUid) {
1968                     mimeBody = messageList.cachedMimeBody;
1969                     mimeMessage = messageList.cachedMimeMessage;
1970                     LOGGER.debug("Got message content for " + imapUid + " from cache");
1971                 } else {
1972                     // load and parse message
1973                     mimeBody = new SharedByteArrayInputStream(getContent(this));
1974                     mimeMessage = new MimeMessage(null, mimeBody);
1975                     mimeBody.reset();
1976                     // workaround for Exchange 2003 ActiveSync bug
1977                     if (mimeMessage.getHeader("MAIL FROM") != null) {
1978                         mimeBody = (SharedByteArrayInputStream) mimeMessage.getRawInputStream();
1979                         mimeMessage = new MimeMessage(null, mimeBody);
1980                         mimeBody.reset();
1981                     }
1982                     LOGGER.debug("Downloaded full message content for IMAP UID " + imapUid + " (" + mimeBody.available() + " bytes)");
1983                 }
1984             }
1985         }
1986 
1987         /**
1988          * Get message content as a Mime message.
1989          *
1990          * @return mime message
1991          * @throws IOException        on error
1992          * @throws MessagingException on error
1993          */
1994         public MimeMessage getMimeMessage() throws IOException, MessagingException {
1995             loadMimeMessage();
1996             return mimeMessage;
1997         }
1998 
1999         public Enumeration getMatchingHeaderLinesFromHeaders(String[] headerNames) throws MessagingException, IOException {
2000             Enumeration result = null;
2001             if (mimeMessage == null) {
2002                 // message not loaded, try to get headers only
2003                 InputStream headers = getMimeHeaders();
2004                 if (headers != null) {
2005                     InternetHeaders internetHeaders = new InternetHeaders(headers);
2006                     if (internetHeaders.getHeader("Subject") == null) {
2007                         // invalid header content
2008                         return null;
2009                     }
2010                     if (headerNames == null) {
2011                         result = internetHeaders.getAllHeaderLines();
2012                     } else {
2013                         result = internetHeaders.getMatchingHeaderLines(headerNames);
2014                     }
2015                 }
2016             }
2017             return result;
2018         }
2019 
2020         public Enumeration getMatchingHeaderLines(String[] headerNames) throws MessagingException, IOException {
2021             Enumeration result = getMatchingHeaderLinesFromHeaders(headerNames);
2022             if (result == null) {
2023                 if (headerNames == null) {
2024                     result = getMimeMessage().getAllHeaderLines();
2025                 } else {
2026                     result = getMimeMessage().getMatchingHeaderLines(headerNames);
2027                 }
2028 
2029             }
2030             return result;
2031         }
2032 
2033         protected abstract InputStream getMimeHeaders();
2034 
2035         /**
2036          * Get message body size.
2037          *
2038          * @return mime message size
2039          * @throws IOException        on error
2040          * @throws MessagingException on error
2041          */
2042         public int getMimeMessageSize() throws IOException, MessagingException {
2043             loadMimeMessage();
2044             mimeBody.reset();
2045             return mimeBody.available();
2046         }
2047 
2048         /**
2049          * Get message body input stream.
2050          *
2051          * @return mime message InputStream
2052          * @throws IOException        on error
2053          * @throws MessagingException on error
2054          */
2055         public InputStream getRawInputStream() throws IOException, MessagingException {
2056             loadMimeMessage();
2057             mimeBody.reset();
2058             return mimeBody;
2059         }
2060 
2061 
2062         /**
2063          * Drop mime message to avoid keeping message content in memory,
2064          * keep a single message in MessageList cache to handle chunked fetch.
2065          */
2066         public void dropMimeMessage() {
2067             // update single message cache
2068             if (mimeMessage != null) {
2069                 messageList.cachedMessageImapUid = imapUid;
2070                 messageList.cachedMimeBody = mimeBody;
2071                 messageList.cachedMimeMessage = mimeMessage;
2072             }
2073             // drop curent message body to save memory
2074             mimeMessage = null;
2075             mimeBody = null;
2076         }
2077 
2078         public boolean isLoaded() {
2079             return mimeMessage != null;
2080         }
2081 
2082         /**
2083          * Delete message.
2084          *
2085          * @throws IOException on error
2086          */
2087         public void delete() throws IOException {
2088             deleteMessage(this);
2089         }
2090 
2091         /**
2092          * Move message to trash, mark message read.
2093          *
2094          * @throws IOException on error
2095          */
2096         public void moveToTrash() throws IOException {
2097             markRead();
2098 
2099             ExchangeSession.this.moveToTrash(this);
2100         }
2101 
2102         /**
2103          * Mark message as read.
2104          *
2105          * @throws IOException on error
2106          */
2107         public void markRead() throws IOException {
2108             HashMap<String, String> properties = new HashMap<String, String>();
2109             properties.put("read", "1");
2110             updateMessage(this, properties);
2111         }
2112 
2113         /**
2114          * Comparator to sort messages by IMAP uid
2115          *
2116          * @param message other message
2117          * @return imapUid comparison result
2118          */
2119         public int compareTo(Message message) {
2120             long compareValue = (imapUid - message.imapUid);
2121             if (compareValue > 0) {
2122                 return 1;
2123             } else if (compareValue < 0) {
2124                 return -1;
2125             } else {
2126                 return 0;
2127             }
2128         }
2129 
2130         /**
2131          * Override equals, compare IMAP uids
2132          *
2133          * @param message other message
2134          * @return true if IMAP uids are equal
2135          */
2136         @Override
2137         public boolean equals(Object message) {
2138             return message instanceof Message && imapUid == ((Message) message).imapUid;
2139         }
2140 
2141         /**
2142          * Override hashCode, return imapUid hashcode.
2143          *
2144          * @return imapUid hashcode
2145          */
2146         @Override
2147         public int hashCode() {
2148             return (int) (imapUid ^ (imapUid >>> 32));
2149         }
2150 
2151         public String removeFlag(String flag) {
2152             if (keywords != null) {
2153                 final String exchangeFlag = convertFlagToKeyword(flag);
2154                 Set<String> keywordSet = new HashSet<String>();
2155                 String[] keywordArray = keywords.split(",");
2156                 for (String value : keywordArray) {
2157                     if (!value.equalsIgnoreCase(exchangeFlag)) {
2158                         keywordSet.add(value);
2159                     }
2160                 }
2161                 keywords = StringUtil.join(keywordSet, ",");
2162             }
2163             return keywords;
2164         }
2165 
2166         public String addFlag(String flag) {
2167             final String exchangeFlag = convertFlagToKeyword(flag);
2168             HashSet<String> keywordSet = new HashSet<String>();
2169             boolean hasFlag = false;
2170             if (keywords != null) {
2171                 String[] keywordArray = keywords.split(",");
2172                 for (String value : keywordArray) {
2173                     keywordSet.add(value);
2174                     if (value.equalsIgnoreCase(exchangeFlag)) {
2175                         hasFlag = true;
2176                     }
2177                 }
2178             }
2179             if (!hasFlag) {
2180                 keywordSet.add(exchangeFlag);
2181             }
2182             keywords = StringUtil.join(keywordSet, ",");
2183             return keywords;
2184         }
2185 
2186         public String setFlags(HashSet<String> flags) {
2187             HashSet<String> keywordSet = new HashSet<String>();
2188             for (String flag : flags) {
2189                 keywordSet.add(convertFlagToKeyword(flag));
2190             }
2191             keywords = StringUtil.join(keywordSet, ",");
2192             return keywords;
2193         }
2194 
2195     }
2196 
2197     /**
2198      * Message list, includes a single messsage cache
2199      */
2200     public static class MessageList extends ArrayList<Message> {
2201         /**
2202          * Cached message content parsed in a MIME message.
2203          */
2204         protected transient MimeMessage cachedMimeMessage;
2205         /**
2206          * Cached message uid.
2207          */
2208         protected transient long cachedMessageImapUid;
2209         /**
2210          * Cached unparsed message
2211          */
2212         protected transient SharedByteArrayInputStream cachedMimeBody;
2213 
2214     }
2215 
2216     /**
2217      * Generic folder item.
2218      */
2219     public abstract static class Item extends HashMap<String, String> {
2220         protected String folderPath;
2221         protected String itemName;
2222         protected String permanentUrl;
2223         /**
2224          * Display name.
2225          */
2226         public String displayName;
2227         /**
2228          * item etag
2229          */
2230         public String etag;
2231         protected String noneMatch;
2232 
2233         /**
2234          * Build item instance.
2235          *
2236          * @param folderPath folder path
2237          * @param itemName   item name class
2238          * @param etag       item etag
2239          * @param noneMatch  none match flag
2240          */
2241         public Item(String folderPath, String itemName, String etag, String noneMatch) {
2242             this.folderPath = folderPath;
2243             this.itemName = itemName;
2244             this.etag = etag;
2245             this.noneMatch = noneMatch;
2246         }
2247 
2248         /**
2249          * Default constructor.
2250          */
2251         protected Item() {
2252         }
2253 
2254         /**
2255          * Return item content type
2256          *
2257          * @return content type
2258          */
2259         public abstract String getContentType();
2260 
2261         /**
2262          * Retrieve item body from Exchange
2263          *
2264          * @return item body
2265          * @throws HttpException on error
2266          */
2267         public abstract String getBody() throws IOException;
2268 
2269         /**
2270          * Get event name (file name part in URL).
2271          *
2272          * @return event name
2273          */
2274         public String getName() {
2275             return itemName;
2276         }
2277 
2278         /**
2279          * Get event etag (last change tag).
2280          *
2281          * @return event etag
2282          */
2283         public String getEtag() {
2284             return etag;
2285         }
2286 
2287         /**
2288          * Set item href.
2289          *
2290          * @param href item href
2291          */
2292         public void setHref(String href) {
2293             int index = href.lastIndexOf('/');
2294             if (index >= 0) {
2295                 folderPath = href.substring(0, index);
2296                 itemName = href.substring(index + 1);
2297             } else {
2298                 throw new IllegalArgumentException(href);
2299             }
2300         }
2301 
2302         /**
2303          * Return item href.
2304          *
2305          * @return item href
2306          */
2307         public String getHref() {
2308             return folderPath + '/' + itemName;
2309         }
2310     }
2311 
2312     /**
2313      * Contact object
2314      */
2315     public abstract class Contact extends Item {
2316 
2317         /**
2318          * @inheritDoc
2319          */
2320         public Contact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) {
2321             super(folderPath, itemName.endsWith(".vcf") ? itemName.substring(0, itemName.length() - 3) + "EML" : itemName, etag, noneMatch);
2322             this.putAll(properties);
2323         }
2324 
2325         /**
2326          * @inheritDoc
2327          */
2328         protected Contact() {
2329         }
2330 
2331         /**
2332          * Convert EML extension to vcf.
2333          *
2334          * @return item name
2335          */
2336         @Override
2337         public String getName() {
2338             String name = super.getName();
2339             if (name.endsWith(".EML")) {
2340                 name = name.substring(0, name.length() - 3) + "vcf";
2341             }
2342             return name;
2343         }
2344 
2345         /**
2346          * Set contact name
2347          *
2348          * @param name contact name
2349          */
2350         public void setName(String name) {
2351             this.itemName = name;
2352         }
2353 
2354         /**
2355          * Compute vcard uid from name.
2356          *
2357          * @return uid
2358          * @throws URIException on error
2359          */
2360         protected String getUid() throws URIException {
2361             String uid = getName();
2362             int dotIndex = uid.lastIndexOf('.');
2363             if (dotIndex > 0) {
2364                 uid = uid.substring(0, dotIndex);
2365             }
2366             return URIUtil.encodePath(uid);
2367         }
2368 
2369         @Override
2370         public String getContentType() {
2371             return "text/vcard";
2372         }
2373 
2374 
2375         @Override
2376         public String getBody() throws HttpException {
2377             // build RFC 2426 VCard from contact information
2378             VCardWriter writer = new VCardWriter();
2379             writer.startCard();
2380             writer.appendProperty("UID", getUid());
2381             // common name
2382             writer.appendProperty("FN", get("cn"));
2383             // RFC 2426: Family Name, Given Name, Additional Names, Honorific Prefixes, and Honorific Suffixes
2384             writer.appendProperty("N", get("sn"), get("givenName"), get("middlename"), get("personaltitle"), get("namesuffix"));
2385 
2386             writer.appendProperty("TEL;TYPE=cell", get("mobile"));
2387             writer.appendProperty("TEL;TYPE=work", get("telephoneNumber"));
2388             writer.appendProperty("TEL;TYPE=home", get("homePhone"));
2389             writer.appendProperty("TEL;TYPE=fax", get("facsimiletelephonenumber"));
2390             writer.appendProperty("TEL;TYPE=pager", get("pager"));
2391             writer.appendProperty("TEL;TYPE=car", get("othermobile"));
2392             writer.appendProperty("TEL;TYPE=home,fax", get("homefax"));
2393             writer.appendProperty("TEL;TYPE=isdn", get("internationalisdnnumber"));
2394             writer.appendProperty("TEL;TYPE=msg", get("otherTelephone"));
2395 
2396             // The structured type value corresponds, in sequence, to the post office box; the extended address;
2397             // the street address; the locality (e.g., city); the region (e.g., state or province);
2398             // the postal code; the country name
2399             writer.appendProperty("ADR;TYPE=home",
2400                     get("homepostofficebox"), null, get("homeStreet"), get("homeCity"), get("homeState"), get("homePostalCode"), get("homeCountry"));
2401             writer.appendProperty("ADR;TYPE=work",
2402                     get("postofficebox"), get("roomnumber"), get("street"), get("l"), get("st"), get("postalcode"), get("co"));
2403             writer.appendProperty("ADR;TYPE=other",
2404                     get("otherpostofficebox"), null, get("otherstreet"), get("othercity"), get("otherstate"), get("otherpostalcode"), get("othercountry"));
2405 
2406             writer.appendProperty("EMAIL;TYPE=work", get("smtpemail1"));
2407             writer.appendProperty("EMAIL;TYPE=home", get("smtpemail2"));
2408             writer.appendProperty("EMAIL;TYPE=other", get("smtpemail3"));
2409 
2410             writer.appendProperty("ORG", get("o"), get("department"));
2411             writer.appendProperty("URL;TYPE=work", get("businesshomepage"));
2412             writer.appendProperty("URL;TYPE=home", get("personalHomePage"));
2413             writer.appendProperty("TITLE", get("title"));
2414             writer.appendProperty("NOTE", get("description"));
2415 
2416             writer.appendProperty("CUSTOM1", get("extensionattribute1"));
2417             writer.appendProperty("CUSTOM2", get("extensionattribute2"));
2418             writer.appendProperty("CUSTOM3", get("extensionattribute3"));
2419             writer.appendProperty("CUSTOM4", get("extensionattribute4"));
2420 
2421             writer.appendProperty("ROLE", get("profession"));
2422             writer.appendProperty("NICKNAME", get("nickname"));
2423             writer.appendProperty("X-AIM", get("im"));
2424 
2425             writer.appendProperty("BDAY", convertZuluDateToBday(get("bday")));
2426             writer.appendProperty("ANNIVERSARY", convertZuluDateToBday(get("anniversary")));
2427 
2428             String gender = get("gender");
2429             if ("1".equals(gender)) {
2430                 writer.appendProperty("SEX", "2");
2431             } else if ("2".equals(gender)) {
2432                 writer.appendProperty("SEX", "1");
2433             }
2434 
2435             writer.appendProperty("CATEGORIES", get("keywords"));
2436 
2437             writer.appendProperty("FBURL", get("fburl"));
2438 
2439             if ("1".equals(get("private"))) {
2440                 writer.appendProperty("CLASS", "PRIVATE");
2441             }
2442 
2443             writer.appendProperty("X-ASSISTANT", get("secretarycn"));
2444             writer.appendProperty("X-MANAGER", get("manager"));
2445             writer.appendProperty("X-SPOUSE", get("spousecn"));
2446 
2447             writer.appendProperty("REV", get("lastmodified"));
2448 
2449             if ("true".equals(get("haspicture"))) {
2450                 try {
2451                     ContactPhoto contactPhoto = getContactPhoto(this);
2452                     writer.writeLine("PHOTO;BASE64;TYPE=\"" + contactPhoto.contentType + "\";ENCODING=\"b\":");
2453                     writer.writeLine(contactPhoto.content, true);
2454                 } catch (IOException e) {
2455                     LOGGER.warn("Unable to get photo from contact " + this.get("cn"));
2456                 }
2457             }
2458 
2459             writer.endCard();
2460             return writer.toString();
2461         }
2462 
2463     }
2464 
2465     /**
2466      * Calendar event object.
2467      */
2468     public abstract class Event extends Item {
2469         protected String contentClass;
2470         protected String subject;
2471         protected VCalendar vCalendar;
2472 
2473         /**
2474          * @inheritDoc
2475          */
2476         public Event(String folderPath, String itemName, String contentClass, String itemBody, String etag, String noneMatch) throws IOException {
2477             super(folderPath, itemName, etag, noneMatch);
2478             this.contentClass = contentClass;
2479             fixICS(itemBody.getBytes("UTF-8"), false);
2480             // fix task item name
2481             if (vCalendar.isTodo() && this.itemName.endsWith(".ics")) {
2482                 this.itemName = itemName.substring(0, itemName.length() - 3) + "EML";
2483             }
2484         }
2485 
2486         /**
2487          * @inheritDoc
2488          */
2489         protected Event() {
2490         }
2491 
2492         @Override
2493         public String getContentType() {
2494             return "text/calendar;charset=UTF-8";
2495         }
2496 
2497         @Override
2498         public String getBody() throws IOException {
2499             if (vCalendar == null) {
2500                 fixICS(getEventContent(), true);
2501             }
2502             return vCalendar.toString();
2503         }
2504 
2505         protected HttpException buildHttpException(Exception e) {
2506             String message = "Unable to get event " + getName() + " subject: " + subject + " at " + permanentUrl + ": " + e.getMessage();
2507             LOGGER.warn(message);
2508             return new HttpException(message);
2509         }
2510 
2511         /**
2512          * Retrieve item body from Exchange
2513          *
2514          * @return item content
2515          * @throws HttpException on error
2516          */
2517         public abstract byte[] getEventContent() throws IOException;
2518 
2519         protected static final String TEXT_CALENDAR = "text/calendar";
2520         protected static final String APPLICATION_ICS = "application/ics";
2521 
2522         protected boolean isCalendarContentType(String contentType) {
2523             return TEXT_CALENDAR.regionMatches(true, 0, contentType, 0, TEXT_CALENDAR.length()) ||
2524                     APPLICATION_ICS.regionMatches(true, 0, contentType, 0, APPLICATION_ICS.length());
2525         }
2526 
2527         protected MimePart getCalendarMimePart(MimeMultipart multiPart) throws IOException, MessagingException {
2528             MimePart bodyPart = null;
2529             for (int i = 0; i < multiPart.getCount(); i++) {
2530                 String contentType = multiPart.getBodyPart(i).getContentType();
2531                 if (isCalendarContentType(contentType)) {
2532                     bodyPart = (MimePart) multiPart.getBodyPart(i);
2533                     break;
2534                 } else if (contentType.startsWith("multipart")) {
2535                     Object content = multiPart.getBodyPart(i).getContent();
2536                     if (content instanceof MimeMultipart) {
2537                         bodyPart = getCalendarMimePart((MimeMultipart) content);
2538                     }
2539                 }
2540             }
2541 
2542             return bodyPart;
2543         }
2544 
2545         /**
2546          * Load ICS content from MIME message input stream
2547          *
2548          * @param mimeInputStream mime message input stream
2549          * @return mime message ics attachment body
2550          * @throws IOException        on error
2551          * @throws MessagingException on error
2552          */
2553         protected byte[] getICS(InputStream mimeInputStream) throws IOException, MessagingException {
2554             byte[] result;
2555             MimeMessage mimeMessage = new MimeMessage(null, mimeInputStream);
2556             String[] contentClassHeader = mimeMessage.getHeader("Content-class");
2557             // task item, return null
2558             if (contentClassHeader != null && contentClassHeader.length > 0 && "urn:content-classes:task".equals(contentClassHeader[0])) {
2559                 return null;
2560             }
2561             Object mimeBody = mimeMessage.getContent();
2562             MimePart bodyPart = null;
2563             if (mimeBody instanceof MimeMultipart) {
2564                 bodyPart = getCalendarMimePart((MimeMultipart) mimeBody);
2565             } else if (isCalendarContentType(mimeMessage.getContentType())) {
2566                 // no multipart, single body
2567                 bodyPart = mimeMessage;
2568             }
2569 
2570             if (bodyPart != null) {
2571                 ByteArrayOutputStream baos = new ByteArrayOutputStream();
2572                 bodyPart.getDataHandler().writeTo(baos);
2573                 baos.close();
2574                 result = baos.toByteArray();
2575             } else {
2576                 ByteArrayOutputStream baos = new ByteArrayOutputStream();
2577                 mimeMessage.writeTo(baos);
2578                 baos.close();
2579                 throw new DavMailException("EXCEPTION_INVALID_MESSAGE_CONTENT", new String(baos.toByteArray(), "UTF-8"));
2580             }
2581             return result;
2582         }
2583 
2584         protected void fixICS(byte[] icsContent, boolean fromServer) throws IOException {
2585             if (LOGGER.isDebugEnabled() && fromServer) {
2586                 dumpIndex++;
2587                 String icsBody = new String(icsContent, "UTF-8");
2588                 dumpICS(icsBody, fromServer, false);
2589                 LOGGER.debug("Vcalendar body received from server:\n" + icsBody);
2590             }
2591             vCalendar = new VCalendar(icsContent, getEmail(), getVTimezone());
2592             vCalendar.fixVCalendar(fromServer);
2593             if (LOGGER.isDebugEnabled() && !fromServer) {
2594                 String resultString = vCalendar.toString();
2595                 LOGGER.debug("Fixed Vcalendar body to server:\n" + resultString);
2596                 dumpICS(resultString, fromServer, true);
2597             }
2598         }
2599 
2600         protected void dumpICS(String icsBody, boolean fromServer, boolean after) {
2601             String logFileDirectory = Settings.getLogFileDirectory();
2602 
2603             // additional setting to activate ICS dump (not available in GUI)
2604             int dumpMax = Settings.getIntProperty("davmail.dumpICS");
2605             if (dumpMax > 0) {
2606                 if (dumpIndex > dumpMax) {
2607                     // Delete the oldest dump file
2608                     final int oldest = dumpIndex - dumpMax;
2609                     try {
2610                         File[] oldestFiles = (new File(logFileDirectory)).listFiles(new FilenameFilter() {
2611                             public boolean accept(File dir, String name) {
2612                                 if (name.endsWith(".ics")) {
2613                                     int dashIndex = name.indexOf('-');
2614                                     if (dashIndex > 0) {
2615                                         try {
2616                                             int fileIndex = Integer.parseInt(name.substring(0, dashIndex));
2617                                             return fileIndex < oldest;
2618                                         } catch (NumberFormatException nfe) {
2619                                             // ignore
2620                                         }
2621                                     }
2622                                 }
2623                                 return false;
2624                             }
2625                         });
2626                         if (oldestFiles != null) {
2627                             for (File file : oldestFiles) {
2628                                 if (!file.delete()) {
2629                                     LOGGER.warn("Unable to delete " + file.getAbsolutePath());
2630                                 }
2631                             }
2632                         }
2633                     } catch (Exception ex) {
2634                         LOGGER.warn("Error deleting ics dump: " + ex.getMessage());
2635                     }
2636                 }
2637 
2638                 StringBuilder filePath = new StringBuilder();
2639                 filePath.append(logFileDirectory).append('/')
2640                         .append(dumpIndex)
2641                         .append(after ? "-to" : "-from")
2642                         .append((after ^ fromServer) ? "-server" : "-client")
2643                         .append(".ics");
2644                 if ((icsBody != null) && (icsBody.length() > 0)) {
2645                     OutputStreamWriter writer = null;
2646                     try {
2647                         writer = new OutputStreamWriter(new FileOutputStream(filePath.toString()), "UTF-8");
2648                         writer.write(icsBody);
2649                     } catch (IOException e) {
2650                         LOGGER.error(e);
2651                     } finally {
2652                         if (writer != null) {
2653                             try {
2654                                 writer.close();
2655                             } catch (IOException e) {
2656                                 LOGGER.error(e);
2657                             }
2658                         }
2659                     }
2660 
2661 
2662                 }
2663             }
2664 
2665         }
2666 
2667         /**
2668          * Build Mime body for event or event message.
2669          *
2670          * @return mimeContent as byte array or null
2671          * @throws IOException on error
2672          */
2673         public byte[] createMimeContent() throws IOException {
2674             String boundary = UUID.randomUUID().toString();
2675             ByteArrayOutputStream baos = new ByteArrayOutputStream();
2676             MimeOutputStreamWriter writer = new MimeOutputStreamWriter(baos);
2677 
2678             writer.writeHeader("Content-Transfer-Encoding", "7bit");
2679             writer.writeHeader("Content-class", contentClass);
2680             // append date
2681             writer.writeHeader("Date", new Date());
2682 
2683             // Make sure invites have a proper subject line
2684             String vEventSubject = vCalendar.getFirstVeventPropertyValue("SUMMARY");
2685             if (vEventSubject == null) {
2686                 vEventSubject = BundleMessage.format("MEETING_REQUEST");
2687             }
2688 
2689             // Write a part of the message that contains the
2690             // ICS description so that invites contain the description text
2691             String description = vCalendar.getFirstVeventPropertyValue("DESCRIPTION");
2692 
2693             // handle notifications
2694             if ("urn:content-classes:calendarmessage".equals(contentClass)) {
2695                 // need to parse attendees and organizer to build recipients
2696                 VCalendar.Recipients recipients = vCalendar.getRecipients(true);
2697                 String to;
2698                 String cc;
2699                 String notificationSubject;
2700                 if (email.equalsIgnoreCase(recipients.organizer)) {
2701                     // current user is organizer => notify all
2702                     to = recipients.attendees;
2703                     cc = recipients.optionalAttendees;
2704                     notificationSubject = subject;
2705                 } else {
2706                     String status = vCalendar.getAttendeeStatus();
2707                     // notify only organizer
2708                     to = recipients.organizer;
2709                     cc = null;
2710                     notificationSubject = (status != null) ? (BundleMessage.format(status) + vEventSubject) : subject;
2711                     description = "";
2712                 }
2713 
2714                 // Allow end user notification edit
2715                 if (Settings.getBooleanProperty("davmail.caldavEditNotifications")) {
2716                     // create notification edit dialog
2717                     NotificationDialog notificationDialog = new NotificationDialog(to,
2718                             cc, notificationSubject, description);
2719                     if (!notificationDialog.getSendNotification()) {
2720                         LOGGER.debug("Notification canceled by user");
2721                         return null;
2722                     }
2723                     // get description from dialog
2724                     to = notificationDialog.getTo();
2725                     cc = notificationDialog.getCc();
2726                     notificationSubject = notificationDialog.getSubject();
2727                     description = notificationDialog.getBody();
2728                 }
2729 
2730                 // do not send notification if no recipients found
2731                 if ((to == null || to.length() == 0) && (cc == null || cc.length() == 0)) {
2732                     return null;
2733                 }
2734 
2735                 writer.writeHeader("To", to);
2736                 writer.writeHeader("Cc", cc);
2737                 writer.writeHeader("Subject", notificationSubject);
2738 
2739 
2740                 if (LOGGER.isDebugEnabled()) {
2741                     StringBuilder logBuffer = new StringBuilder("Sending notification ");
2742                     if (to != null) {
2743                         logBuffer.append("to: ").append(to);
2744                     }
2745                     if (cc != null) {
2746                         logBuffer.append("cc: ").append(cc);
2747                     }
2748                     LOGGER.debug(logBuffer.toString());
2749                 }
2750             } else {
2751                 // need to parse attendees and organizer to build recipients
2752                 VCalendar.Recipients recipients = vCalendar.getRecipients(false);
2753                 // storing appointment, full recipients header
2754                 if (recipients.attendees != null) {
2755                     writer.writeHeader("To", recipients.attendees);
2756                 } else {
2757                     // use current user as attendee
2758                     writer.writeHeader("To", email);
2759                 }
2760                 writer.writeHeader("Cc", recipients.optionalAttendees);
2761 
2762                 if (recipients.organizer != null) {
2763                     writer.writeHeader("From", recipients.organizer);
2764                 } else {
2765                     writer.writeHeader("From", email);
2766                 }
2767             }
2768             if (vCalendar.getMethod() == null) {
2769                 vCalendar.setPropertyValue("METHOD", "REQUEST");
2770             }
2771             writer.writeHeader("MIME-Version", "1.0");
2772             writer.writeHeader("Content-Type", "multipart/alternative;\r\n" +
2773                     "\tboundary=\"----=_NextPart_" + boundary + '\"');
2774             writer.writeLn();
2775             writer.writeLn("This is a multi-part message in MIME format.");
2776             writer.writeLn();
2777             writer.writeLn("------=_NextPart_" + boundary);
2778 
2779             if (description != null && description.length() > 0) {
2780                 writer.writeHeader("Content-Type", "text/plain;\r\n" +
2781                         "\tcharset=\"utf-8\"");
2782                 writer.writeHeader("content-transfer-encoding", "8bit");
2783                 writer.writeLn();
2784                 writer.flush();
2785                 baos.write(description.getBytes("UTF-8"));
2786                 writer.writeLn();
2787                 writer.writeLn("------=_NextPart_" + boundary);
2788             }
2789             writer.writeHeader("Content-class", contentClass);
2790             writer.writeHeader("Content-Type", "text/calendar;\r\n" +
2791                     "\tmethod=" + vCalendar.getMethod() + ";\r\n" +
2792                     "\tcharset=\"utf-8\""
2793             );
2794             writer.writeHeader("Content-Transfer-Encoding", "8bit");
2795             writer.writeLn();
2796             writer.flush();
2797             baos.write(vCalendar.toString().getBytes("UTF-8"));
2798             writer.writeLn();
2799             writer.writeLn("------=_NextPart_" + boundary + "--");
2800             writer.close();
2801             return baos.toByteArray();
2802         }
2803 
2804         /**
2805          * Create or update item
2806          *
2807          * @return action result
2808          * @throws IOException on error
2809          */
2810         public abstract ItemResult createOrUpdate() throws IOException;
2811 
2812     }
2813 
2814     protected abstract Set<String> getItemProperties();
2815 
2816     /**
2817      * Search contacts in provided folder.
2818      *
2819      * @param folderPath Exchange folder path
2820      * @return list of contacts
2821      * @throws IOException on error
2822      */
2823     public List<ExchangeSession.Contact> getAllContacts(String folderPath) throws IOException {
2824         return searchContacts(folderPath, ExchangeSession.CONTACT_ATTRIBUTES, isEqualTo("outlookmessageclass", "IPM.Contact"), 0);
2825     }
2826 
2827 
2828     /**
2829      * Search contacts in provided folder matching the search query.
2830      *
2831      * @param folderPath Exchange folder path
2832      * @param attributes requested attributes
2833      * @param condition  Exchange search query
2834      * @param maxCount   maximum item count
2835      * @return list of contacts
2836      * @throws IOException on error
2837      */
2838     public abstract List<Contact> searchContacts(String folderPath, Set<String> attributes, Condition condition, int maxCount) throws IOException;
2839 
2840     /**
2841      * Search calendar messages in provided folder.
2842      *
2843      * @param folderPath Exchange folder path
2844      * @return list of calendar messages as Event objects
2845      * @throws IOException on error
2846      */
2847     public abstract List<Event> getEventMessages(String folderPath) throws IOException;
2848 
2849     /**
2850      * Search calendar events in provided folder.
2851      *
2852      * @param folderPath Exchange folder path
2853      * @return list of calendar events
2854      * @throws IOException on error
2855      */
2856     public List<Event> getAllEvents(String folderPath) throws IOException {
2857         List<Event> results = searchEvents(folderPath, getCalendarItemCondition(getPastDelayCondition("dtstart")));
2858 
2859         if (!Settings.getBooleanProperty("davmail.caldavDisableTasks", false) && isMainCalendar(folderPath)) {
2860             // retrieve tasks from main tasks folder
2861             results.addAll(searchTasksOnly(TASKS));
2862         }
2863 
2864         return results;
2865     }
2866 
2867     protected abstract Condition getCalendarItemCondition(Condition dateCondition);
2868 
2869     protected Condition getPastDelayCondition(String attribute) {
2870         int caldavPastDelay = Settings.getIntProperty("davmail.caldavPastDelay");
2871         Condition dateCondition = null;
2872         if (caldavPastDelay != 0) {
2873             Calendar cal = Calendar.getInstance();
2874             cal.add(Calendar.DAY_OF_MONTH, -caldavPastDelay);
2875             dateCondition = gt(attribute, formatSearchDate(cal.getTime()));
2876         }
2877         return dateCondition;
2878     }
2879 
2880     protected Condition getRangeCondition(String timeRangeStart, String timeRangeEnd) throws IOException {
2881         try {
2882             SimpleDateFormat parser = getZuluDateFormat();
2883             ExchangeSession.MultiCondition andCondition = and();
2884             if (timeRangeStart != null) {
2885                 andCondition.add(gt("dtend", formatSearchDate(parser.parse(timeRangeStart))));
2886             }
2887             if (timeRangeEnd != null) {
2888                 andCondition.add(lt("dtstart", formatSearchDate(parser.parse(timeRangeEnd))));
2889             }
2890             return andCondition;
2891         } catch (ParseException e) {
2892             throw new IOException(e + " " + e.getMessage());
2893         }
2894     }
2895 
2896     /**
2897      * Search events between start and end.
2898      *
2899      * @param folderPath     Exchange folder path
2900      * @param timeRangeStart date range start in zulu format
2901      * @param timeRangeEnd   date range start in zulu format
2902      * @return list of calendar events
2903      * @throws IOException on error
2904      */
2905     public List<Event> searchEvents(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException {
2906         Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd);
2907         Condition condition = getCalendarItemCondition(dateCondition);
2908 
2909         return searchEvents(folderPath, condition);
2910     }
2911 
2912     /**
2913      * Search events between start and end, exclude tasks.
2914      *
2915      * @param folderPath     Exchange folder path
2916      * @param timeRangeStart date range start in zulu format
2917      * @param timeRangeEnd   date range start in zulu format
2918      * @return list of calendar events
2919      * @throws IOException on error
2920      */
2921     public List<Event> searchEventsOnly(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException {
2922         Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd);
2923         return searchEvents(folderPath, getCalendarItemCondition(dateCondition));
2924     }
2925 
2926     /**
2927      * Search tasks only (VTODO).
2928      *
2929      * @param folderPath Exchange folder path
2930      * @return list of tasks
2931      * @throws IOException on error
2932      */
2933     public List<Event> searchTasksOnly(String folderPath) throws IOException {
2934         return searchEvents(folderPath, and(isEqualTo("outlookmessageclass", "IPM.Task"),
2935                 or(isNull("datecompleted"), getPastDelayCondition("datecompleted"))));
2936     }
2937 
2938     /**
2939      * Search calendar events in provided folder.
2940      *
2941      * @param folderPath Exchange folder path
2942      * @param filter     search filter
2943      * @return list of calendar events
2944      * @throws IOException on error
2945      */
2946     public List<Event> searchEvents(String folderPath, Condition filter) throws IOException {
2947 
2948         Condition privateCondition = null;
2949         if (isSharedFolder(folderPath) && Settings.getBooleanProperty("davmail.excludePrivateEvents", true)) {
2950             LOGGER.debug("Shared or public calendar: exclude private events");
2951             privateCondition = isEqualTo("sensitivity", 0);
2952         }
2953 
2954         return searchEvents(folderPath, getItemProperties(),
2955                 and(filter, privateCondition));
2956     }
2957 
2958     /**
2959      * Search calendar events or messages in provided folder matching the search query.
2960      *
2961      * @param folderPath Exchange folder path
2962      * @param attributes requested attributes
2963      * @param condition  Exchange search query
2964      * @return list of calendar messages as Event objects
2965      * @throws IOException on error
2966      */
2967     public abstract List<Event> searchEvents(String folderPath, Set<String> attributes, Condition condition) throws IOException;
2968 
2969     /**
2970      * convert vcf extension to EML.
2971      *
2972      * @param itemName item name
2973      * @return EML item name
2974      */
2975     protected String convertItemNameToEML(String itemName) {
2976         if (itemName.endsWith(".vcf")) {
2977             return itemName.substring(0, itemName.length() - 3) + "EML";
2978         } else {
2979             return itemName;
2980         }
2981     }
2982 
2983     /**
2984      * Get item named eventName in folder
2985      *
2986      * @param folderPath Exchange folder path
2987      * @param itemName   event name
2988      * @return event object
2989      * @throws IOException on error
2990      */
2991     public abstract Item getItem(String folderPath, String itemName) throws IOException;
2992 
2993     /**
2994      * Contact picture
2995      */
2996     public static class ContactPhoto {
2997         /**
2998          * Contact picture content type (always image/jpeg on read)
2999          */
3000         public String contentType;
3001         /**
3002          * Base64 encoded picture content
3003          */
3004         public String content;
3005     }
3006 
3007     /**
3008      * Retrieve contact photo attached to contact
3009      *
3010      * @param contact address book contact
3011      * @return contact photo
3012      * @throws IOException on error
3013      */
3014     public abstract ContactPhoto getContactPhoto(Contact contact) throws IOException;
3015 
3016 
3017     /**
3018      * Delete event named itemName in folder
3019      *
3020      * @param folderPath Exchange folder path
3021      * @param itemName   item name
3022      * @throws IOException on error
3023      */
3024     public abstract void deleteItem(String folderPath, String itemName) throws IOException;
3025 
3026     /**
3027      * Mark event processed named eventName in folder
3028      *
3029      * @param folderPath Exchange folder path
3030      * @param itemName   item name
3031      * @throws IOException on error
3032      */
3033     public abstract void processItem(String folderPath, String itemName) throws IOException;
3034 
3035 
3036     private static int dumpIndex;
3037 
3038     /**
3039      * Replace iCal4 (Snow Leopard) principal paths with mailto expression
3040      *
3041      * @param value attendee value or ics line
3042      * @return fixed value
3043      */
3044     protected String replaceIcal4Principal(String value) {
3045         if (value != null && value.contains("/principals/__uuids__/")) {
3046             return value.replaceAll("/principals/__uuids__/([^/]*)__AT__([^/]*)/", "mailto:$1@$2");
3047         } else {
3048             return value;
3049         }
3050     }
3051 
3052     /**
3053      * Event result object to hold HTTP status and event etag from an event creation/update.
3054      */
3055     public static class ItemResult {
3056         /**
3057          * HTTP status
3058          */
3059         public int status;
3060         /**
3061          * Event etag from response HTTP header
3062          */
3063         public String etag;
3064     }
3065 
3066     /**
3067      * Build and send the MIME message for the provided ICS event.
3068      *
3069      * @param icsBody event in iCalendar format
3070      * @return HTTP status
3071      * @throws IOException on error
3072      */
3073     public abstract int sendEvent(String icsBody) throws IOException;
3074 
3075     /**
3076      * Create or update item (event or contact) on the Exchange server
3077      *
3078      * @param folderPath Exchange folder path
3079      * @param itemName   event name
3080      * @param itemBody   event body in iCalendar format
3081      * @param etag       previous event etag to detect concurrent updates
3082      * @param noneMatch  if-none-match header value
3083      * @return HTTP response event result (status and etag)
3084      * @throws IOException on error
3085      */
3086     public ItemResult createOrUpdateItem(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException {
3087         if (itemBody.startsWith("BEGIN:VCALENDAR")) {
3088             return internalCreateOrUpdateEvent(folderPath, itemName, "urn:content-classes:appointment", itemBody, etag, noneMatch);
3089         } else if (itemBody.startsWith("BEGIN:VCARD")) {
3090             return createOrUpdateContact(folderPath, itemName, itemBody, etag, noneMatch);
3091         } else {
3092             throw new IOException(BundleMessage.format("EXCEPTION_INVALID_MESSAGE_CONTENT", itemBody));
3093         }
3094     }
3095 
3096     static final String[] VCARD_N_PROPERTIES = {"sn", "givenName", "middlename", "personaltitle", "namesuffix"};
3097     static final String[] VCARD_ADR_HOME_PROPERTIES = {"homepostofficebox", null, "homeStreet", "homeCity", "homeState", "homePostalCode", "homeCountry"};
3098     static final String[] VCARD_ADR_WORK_PROPERTIES = {"postofficebox", "roomnumber", "street", "l", "st", "postalcode", "co"};
3099     static final String[] VCARD_ADR_OTHER_PROPERTIES = {"otherpostofficebox", null, "otherstreet", "othercity", "otherstate", "otherpostalcode", "othercountry"};
3100     static final String[] VCARD_ORG_PROPERTIES = {"o", "department"};
3101 
3102     protected void convertContactProperties(Map<String, String> properties, String[] contactProperties, List<String> values) {
3103         for (int i = 0; i < values.size() && i < contactProperties.length; i++) {
3104             if (contactProperties[i] != null) {
3105                 properties.put(contactProperties[i], values.get(i));
3106             }
3107         }
3108     }
3109 
3110     protected ItemResult createOrUpdateContact(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException {
3111         // parse VCARD body to build contact property map
3112         Map<String, String> properties = new HashMap<String, String>();
3113         properties.put("outlookmessageclass", "IPM.Contact");
3114 
3115         VObject vcard = new VObject(new ICSBufferedReader(new StringReader(itemBody)));
3116         for (VProperty property : vcard.getProperties()) {
3117             if ("FN".equals(property.getKey())) {
3118                 properties.put("cn", property.getValue());
3119                 properties.put("subject", property.getValue());
3120                 properties.put("fileas", property.getValue());
3121 
3122             } else if ("N".equals(property.getKey())) {
3123                 convertContactProperties(properties, VCARD_N_PROPERTIES, property.getValues());
3124             } else if ("NICKNAME".equals(property.getKey())) {
3125                 properties.put("nickname", property.getValue());
3126             } else if ("TEL".equals(property.getKey())) {
3127                 if (property.hasParam("TYPE", "cell") || property.hasParam("X-GROUP", "cell")) {
3128                     properties.put("mobile", property.getValue());
3129                 } else if (property.hasParam("TYPE", "work") || property.hasParam("X-GROUP", "work")) {
3130                     properties.put("telephoneNumber", property.getValue());
3131                 } else if (property.hasParam("TYPE", "home") || property.hasParam("X-GROUP", "home")) {
3132                     properties.put("homePhone", property.getValue());
3133                 } else if (property.hasParam("TYPE", "fax")) {
3134                     if (property.hasParam("TYPE", "home")) {
3135                         properties.put("homefax", property.getValue());
3136                     } else {
3137                         properties.put("facsimiletelephonenumber", property.getValue());
3138                     }
3139                 } else if (property.hasParam("TYPE", "pager")) {
3140                     properties.put("pager", property.getValue());
3141                 } else if (property.hasParam("TYPE", "car")) {
3142                     properties.put("othermobile", property.getValue());
3143                 } else {
3144                     properties.put("otherTelephone", property.getValue());
3145                 }
3146             } else if ("ADR".equals(property.getKey())) {
3147                 // address
3148                 if (property.hasParam("TYPE", "home")) {
3149                     convertContactProperties(properties, VCARD_ADR_HOME_PROPERTIES, property.getValues());
3150                 } else if (property.hasParam("TYPE", "work")) {
3151                     convertContactProperties(properties, VCARD_ADR_WORK_PROPERTIES, property.getValues());
3152                     // any other type goes to other address
3153                 } else {
3154                     convertContactProperties(properties, VCARD_ADR_OTHER_PROPERTIES, property.getValues());
3155                 }
3156             } else if ("EMAIL".equals(property.getKey())) {
3157                 if (property.hasParam("TYPE", "home")) {
3158                     properties.put("email2", property.getValue());
3159                     properties.put("smtpemail2", property.getValue());
3160                 } else if (property.hasParam("TYPE", "other")) {
3161                     properties.put("email3", property.getValue());
3162                     properties.put("smtpemail3", property.getValue());
3163                 } else {
3164                     properties.put("email1", property.getValue());
3165                     properties.put("smtpemail1", property.getValue());
3166                 }
3167             } else if ("ORG".equals(property.getKey())) {
3168                 convertContactProperties(properties, VCARD_ORG_PROPERTIES, property.getValues());
3169             } else if ("URL".equals(property.getKey())) {
3170                 if (property.hasParam("TYPE", "work")) {
3171                     properties.put("businesshomepage", property.getValue());
3172                 } else if (property.hasParam("TYPE", "home")) {
3173                     properties.put("personalHomePage", property.getValue());
3174                 } else {
3175                     // default: set personal home page
3176                     properties.put("personalHomePage", property.getValue());
3177                 }
3178             } else if ("TITLE".equals(property.getKey())) {
3179                 properties.put("title", property.getValue());
3180             } else if ("NOTE".equals(property.getKey())) {
3181                 properties.put("description", property.getValue());
3182             } else if ("CUSTOM1".equals(property.getKey())) {
3183                 properties.put("extensionattribute1", property.getValue());
3184             } else if ("CUSTOM2".equals(property.getKey())) {
3185                 properties.put("extensionattribute2", property.getValue());
3186             } else if ("CUSTOM3".equals(property.getKey())) {
3187                 properties.put("extensionattribute3", property.getValue());
3188             } else if ("CUSTOM4".equals(property.getKey())) {
3189                 properties.put("extensionattribute4", property.getValue());
3190             } else if ("ROLE".equals(property.getKey())) {
3191                 properties.put("profession", property.getValue());
3192             } else if ("X-AIM".equals(property.getKey())) {
3193                 properties.put("im", property.getValue());
3194             } else if ("BDAY".equals(property.getKey())) {
3195                 properties.put("bday", convertBDayToZulu(property.getValue()));
3196             } else if ("ANNIVERSARY".equals(property.getKey()) || "X-ANNIVERSARY".equals(property.getKey())) {
3197                 properties.put("anniversary", convertBDayToZulu(property.getValue()));
3198             } else if ("CATEGORIES".equals(property.getKey())) {
3199                 properties.put("keywords", property.getValue());
3200             } else if ("CLASS".equals(property.getKey())) {
3201                 if ("PUBLIC".equals(property.getValue())) {
3202                     properties.put("sensitivity", "0");
3203                     properties.put("private", "false");
3204                 } else {
3205                     properties.put("sensitivity", "2");
3206                     properties.put("private", "true");
3207                 }
3208             } else if ("SEX".equals(property.getKey())) {
3209                 String propertyValue = property.getValue();
3210                 if ("1".equals(propertyValue)) {
3211                     properties.put("gender", "2");
3212                 } else if ("2".equals(propertyValue)) {
3213                     properties.put("gender", "1");
3214                 }
3215             } else if ("FBURL".equals(property.getKey())) {
3216                 properties.put("fburl", property.getValue());
3217             } else if ("X-ASSISTANT".equals(property.getKey())) {
3218                 properties.put("secretarycn", property.getValue());
3219             } else if ("X-MANAGER".equals(property.getKey())) {
3220                 properties.put("manager", property.getValue());
3221             } else if ("X-SPOUSE".equals(property.getKey())) {
3222                 properties.put("spousecn", property.getValue());
3223             } else if ("PHOTO".equals(property.getKey())) {
3224                 properties.put("photo", property.getValue());
3225                 properties.put("haspicture", "true");
3226             }
3227         }
3228         LOGGER.debug("Create or update contact " + itemName + ": " + properties);
3229         // reset missing properties to null
3230         for (String key : CONTACT_ATTRIBUTES) {
3231             if (!"imapUid".equals(key) && !"etag".equals(key) && !"urlcompname".equals(key)
3232                     && !"lastmodified".equals(key) && !"sensitivity".equals(key) &&
3233                     !properties.containsKey(key)) {
3234                 properties.put(key, null);
3235             }
3236         }
3237         return internalCreateOrUpdateContact(folderPath, itemName, properties, etag, noneMatch);
3238     }
3239 
3240     protected String convertZuluDateToBday(String value) {
3241         String result = null;
3242         if (value != null && value.length() > 0) {
3243             try {
3244                 SimpleDateFormat parser = ExchangeSession.getZuluDateFormat();
3245                 Calendar cal = Calendar.getInstance();
3246                 cal.setTime(parser.parse(value));
3247                 cal.add(Calendar.HOUR_OF_DAY, 12);
3248                 result = ExchangeSession.getVcardBdayFormat().format(cal.getTime());
3249             } catch (ParseException e) {
3250                 LOGGER.warn("Invalid date: " + value);
3251             }
3252         }
3253         return result;
3254     }
3255 
3256     protected String convertBDayToZulu(String value) {
3257         String result = null;
3258         if (value != null && value.length() > 0) {
3259             try {
3260                 SimpleDateFormat parser;
3261                 if (value.length() == 10) {
3262                     parser = ExchangeSession.getVcardBdayFormat();
3263                 } else if (value.length() == 15) {
3264                     parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ENGLISH);
3265                     parser.setTimeZone(GMT_TIMEZONE);
3266                 } else {
3267                     parser = ExchangeSession.getExchangeZuluDateFormat();
3268                 }
3269                 result = ExchangeSession.getExchangeZuluDateFormatMillisecond().format(parser.parse(value));
3270             } catch (ParseException e) {
3271                 LOGGER.warn("Invalid date: " + value);
3272             }
3273         }
3274 
3275         return result;
3276     }
3277 
3278 
3279     protected abstract ItemResult internalCreateOrUpdateContact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) throws IOException;
3280 
3281     protected abstract ItemResult internalCreateOrUpdateEvent(String folderPath, String itemName, String contentClass, String icsBody, String etag, String noneMatch) throws IOException;
3282 
3283     /**
3284      * Get current Exchange alias name from login name
3285      *
3286      * @return user name
3287      */
3288     public String getAliasFromLogin() {
3289         // login is email, not alias
3290         if (this.userName.indexOf('@') >= 0) {
3291             return null;
3292         }
3293         String result = this.userName;
3294         // remove domain name
3295         int index = Math.max(result.indexOf('\\'), result.indexOf('/'));
3296         if (index >= 0) {
3297             result = result.substring(index + 1);
3298         }
3299         return result;
3300     }
3301 
3302     protected String getEmailSuffixFromHostname() {
3303         String domain = httpClient.getHostConfiguration().getHost();
3304         int start = domain.lastIndexOf('.', domain.lastIndexOf('.') - 1);
3305         if (start >= 0) {
3306             return '@' + domain.substring(start + 1);
3307         } else {
3308             return '@' + domain;
3309         }
3310     }
3311 
3312     /**
3313      * Test if folderPath is inside user mailbox.
3314      *
3315      * @param folderPath absolute folder path
3316      * @return true if folderPath is a public or shared folder
3317      */
3318     public abstract boolean isSharedFolder(String folderPath);
3319 
3320     /**
3321      * Test if folderPath is main calendar.
3322      *
3323      * @param folderPath absolute folder path
3324      * @return true if folderPath is a public or shared folder
3325      */
3326     public abstract boolean isMainCalendar(String folderPath) throws IOException;
3327 
3328     static final String MAILBOX_BASE = "/cn=";
3329 
3330     protected void getEmailAndAliasFromOptions() {
3331         synchronized (httpClient.getState()) {
3332             Cookie[] currentCookies = httpClient.getState().getCookies();
3333             // get user mail URL from html body
3334             BufferedReader optionsPageReader = null;
3335             GetMethod optionsMethod = new GetMethod("/owa/?ae=Options&t=About");
3336             try {
3337                 DavGatewayHttpClientFacade.executeGetMethod(httpClient, optionsMethod, false);
3338                 optionsPageReader = new BufferedReader(new InputStreamReader(optionsMethod.getResponseBodyAsStream(), "UTF-8"));
3339                 String line;
3340 
3341                 // find email and alias
3342                 //noinspection StatementWithEmptyBody
3343                 while ((line = optionsPageReader.readLine()) != null
3344                         && (line.indexOf('[') == -1
3345                         || line.indexOf('@') == -1
3346                         || line.indexOf(']') == -1
3347                         || !line.toLowerCase().contains(MAILBOX_BASE))) {
3348                 }
3349                 if (line != null) {
3350                     int start = line.toLowerCase().lastIndexOf(MAILBOX_BASE) + MAILBOX_BASE.length();
3351                     int end = line.indexOf('<', start);
3352                     alias = line.substring(start, end);
3353                     end = line.lastIndexOf(']');
3354                     start = line.lastIndexOf('[', end) + 1;
3355                     email = line.substring(start, end);
3356                 }
3357             } catch (IOException e) {
3358                 // restore cookies on error
3359                 httpClient.getState().addCookies(currentCookies);
3360                 LOGGER.error("Error parsing options page at " + optionsMethod.getPath());
3361             } finally {
3362                 if (optionsPageReader != null) {
3363                     try {
3364                         optionsPageReader.close();
3365                     } catch (IOException e) {
3366                         LOGGER.error("Error parsing options page at " + optionsMethod.getPath());
3367                     }
3368                 }
3369                 optionsMethod.releaseConnection();
3370             }
3371         }
3372     }
3373 
3374     /**
3375      * Get current user email
3376      *
3377      * @return user email
3378      */
3379     public String getEmail() {
3380         return email;
3381     }
3382 
3383     /**
3384      * Get current user alias
3385      *
3386      * @return user email
3387      */
3388     public String getAlias() {
3389         return alias;
3390     }
3391 
3392     /**
3393      * Search global address list
3394      *
3395      * @param condition           search filter
3396      * @param returningAttributes returning attributes
3397      * @param sizeLimit           size limit
3398      * @return matching contacts from gal
3399      * @throws IOException on error
3400      */
3401     public abstract Map<String, Contact> galFind(Condition condition, Set<String> returningAttributes, int sizeLimit) throws IOException;
3402 
3403     /**
3404      * Full Contact attribute list
3405      */
3406     public static final Set<String> CONTACT_ATTRIBUTES = new HashSet<String>();
3407 
3408     static {
3409         CONTACT_ATTRIBUTES.add("imapUid");
3410         CONTACT_ATTRIBUTES.add("etag");
3411         CONTACT_ATTRIBUTES.add("urlcompname");
3412 
3413         CONTACT_ATTRIBUTES.add("extensionattribute1");
3414         CONTACT_ATTRIBUTES.add("extensionattribute2");
3415         CONTACT_ATTRIBUTES.add("extensionattribute3");
3416         CONTACT_ATTRIBUTES.add("extensionattribute4");
3417         CONTACT_ATTRIBUTES.add("bday");
3418         CONTACT_ATTRIBUTES.add("anniversary");
3419         CONTACT_ATTRIBUTES.add("businesshomepage");
3420         CONTACT_ATTRIBUTES.add("personalHomePage");
3421         CONTACT_ATTRIBUTES.add("cn");
3422         CONTACT_ATTRIBUTES.add("co");
3423         CONTACT_ATTRIBUTES.add("department");
3424         CONTACT_ATTRIBUTES.add("smtpemail1");
3425         CONTACT_ATTRIBUTES.add("smtpemail2");
3426         CONTACT_ATTRIBUTES.add("smtpemail3");
3427         CONTACT_ATTRIBUTES.add("facsimiletelephonenumber");
3428         CONTACT_ATTRIBUTES.add("givenName");
3429         CONTACT_ATTRIBUTES.add("homeCity");
3430         CONTACT_ATTRIBUTES.add("homeCountry");
3431         CONTACT_ATTRIBUTES.add("homePhone");
3432         CONTACT_ATTRIBUTES.add("homePostalCode");
3433         CONTACT_ATTRIBUTES.add("homeState");
3434         CONTACT_ATTRIBUTES.add("homeStreet");
3435         CONTACT_ATTRIBUTES.add("homepostofficebox");
3436         CONTACT_ATTRIBUTES.add("l");
3437         CONTACT_ATTRIBUTES.add("manager");
3438         CONTACT_ATTRIBUTES.add("mobile");
3439         CONTACT_ATTRIBUTES.add("namesuffix");
3440         CONTACT_ATTRIBUTES.add("nickname");
3441         CONTACT_ATTRIBUTES.add("o");
3442         CONTACT_ATTRIBUTES.add("pager");
3443         CONTACT_ATTRIBUTES.add("personaltitle");
3444         CONTACT_ATTRIBUTES.add("postalcode");
3445         CONTACT_ATTRIBUTES.add("postofficebox");
3446         CONTACT_ATTRIBUTES.add("profession");
3447         CONTACT_ATTRIBUTES.add("roomnumber");
3448         CONTACT_ATTRIBUTES.add("secretarycn");
3449         CONTACT_ATTRIBUTES.add("sn");
3450         CONTACT_ATTRIBUTES.add("spousecn");
3451         CONTACT_ATTRIBUTES.add("st");
3452         CONTACT_ATTRIBUTES.add("street");
3453         CONTACT_ATTRIBUTES.add("telephoneNumber");
3454         CONTACT_ATTRIBUTES.add("title");
3455         CONTACT_ATTRIBUTES.add("description");
3456         CONTACT_ATTRIBUTES.add("im");
3457         CONTACT_ATTRIBUTES.add("middlename");
3458         CONTACT_ATTRIBUTES.add("lastmodified");
3459         CONTACT_ATTRIBUTES.add("otherstreet");
3460         CONTACT_ATTRIBUTES.add("otherstate");
3461         CONTACT_ATTRIBUTES.add("otherpostofficebox");
3462         CONTACT_ATTRIBUTES.add("otherpostalcode");
3463         CONTACT_ATTRIBUTES.add("othercountry");
3464         CONTACT_ATTRIBUTES.add("othercity");
3465         CONTACT_ATTRIBUTES.add("haspicture");
3466         CONTACT_ATTRIBUTES.add("keywords");
3467         CONTACT_ATTRIBUTES.add("othermobile");
3468         CONTACT_ATTRIBUTES.add("otherTelephone");
3469         CONTACT_ATTRIBUTES.add("gender");
3470         CONTACT_ATTRIBUTES.add("private");
3471         CONTACT_ATTRIBUTES.add("sensitivity");
3472         CONTACT_ATTRIBUTES.add("fburl");
3473     }
3474 
3475     /**
3476      * Get freebusy data string from Exchange.
3477      *
3478      * @param attendee attendee email address
3479      * @param start    start date in Exchange zulu format
3480      * @param end      end date in Exchange zulu format
3481      * @param interval freebusy interval in minutes
3482      * @return freebusy data or null
3483      * @throws IOException on error
3484      */
3485     protected abstract String getFreeBusyData(String attendee, String start, String end, int interval) throws IOException;
3486 
3487     /**
3488      * Get freebusy info for attendee between start and end date.
3489      *
3490      * @param attendee       attendee email
3491      * @param startDateValue start date
3492      * @param endDateValue   end date
3493      * @return FreeBusy info
3494      * @throws IOException on error
3495      */
3496     public FreeBusy getFreebusy(String attendee, String startDateValue, String endDateValue) throws IOException {
3497         // replace ical encoded attendee name
3498         attendee = replaceIcal4Principal(attendee);
3499 
3500         // then check that email address is valid to avoid InvalidSmtpAddress error
3501         if (attendee == null || attendee.indexOf('@') < 0 || attendee.charAt(attendee.length() - 1) == '@') {
3502             return null;
3503         }
3504 
3505         if (attendee.startsWith("mailto:") || attendee.startsWith("MAILTO:")) {
3506             attendee = attendee.substring("mailto:".length());
3507         }
3508 
3509         SimpleDateFormat exchangeZuluDateFormat = getExchangeZuluDateFormat();
3510         SimpleDateFormat icalDateFormat = getZuluDateFormat();
3511 
3512         Date startDate;
3513         Date endDate;
3514         try {
3515             if (startDateValue.length() == 8) {
3516                 startDate = parseDate(startDateValue);
3517             } else {
3518                 startDate = icalDateFormat.parse(startDateValue);
3519             }
3520             if (endDateValue.length() == 8) {
3521                 endDate = parseDate(endDateValue);
3522             } else {
3523                 endDate = icalDateFormat.parse(endDateValue);
3524             }
3525         } catch (ParseException e) {
3526             throw new DavMailException("EXCEPTION_INVALID_DATES", e.getMessage());
3527         }
3528 
3529         FreeBusy freeBusy = null;
3530         String fbdata = getFreeBusyData(attendee, exchangeZuluDateFormat.format(startDate), exchangeZuluDateFormat.format(endDate), FREE_BUSY_INTERVAL);
3531         if (fbdata != null) {
3532             freeBusy = new FreeBusy(icalDateFormat, startDate, fbdata);
3533         }
3534 
3535         if (freeBusy != null && freeBusy.knownAttendee) {
3536             return freeBusy;
3537         } else {
3538             return null;
3539         }
3540     }
3541 
3542     /**
3543      * Exchange to iCalendar Free/Busy parser.
3544      * Free time returns 0, Tentative returns 1, Busy returns 2, and Out of Office (OOF) returns 3
3545      */
3546     public static final class FreeBusy {
3547         final SimpleDateFormat icalParser;
3548         boolean knownAttendee = true;
3549         static final HashMap<Character, String> FBTYPES = new HashMap<Character, String>();
3550 
3551         static {
3552             FBTYPES.put('1', "BUSY-TENTATIVE");
3553             FBTYPES.put('2', "BUSY");
3554             FBTYPES.put('3', "BUSY-UNAVAILABLE");
3555         }
3556 
3557         final HashMap<String, StringBuilder> busyMap = new HashMap<String, StringBuilder>();
3558 
3559         StringBuilder getBusyBuffer(char type) {
3560             String fbType = FBTYPES.get(Character.valueOf(type));
3561             StringBuilder buffer = busyMap.get(fbType);
3562             if (buffer == null) {
3563                 buffer = new StringBuilder();
3564                 busyMap.put(fbType, buffer);
3565             }
3566             return buffer;
3567         }
3568 
3569         void startBusy(char type, Calendar currentCal) {
3570             if (type == '4') {
3571                 knownAttendee = false;
3572             } else if (type != '0') {
3573                 StringBuilder busyBuffer = getBusyBuffer(type);
3574                 if (busyBuffer.length() > 0) {
3575                     busyBuffer.append(',');
3576                 }
3577                 busyBuffer.append(icalParser.format(currentCal.getTime()));
3578             }
3579         }
3580 
3581         void endBusy(char type, Calendar currentCal) {
3582             if (type != '0' && type != '4') {
3583                 getBusyBuffer(type).append('/').append(icalParser.format(currentCal.getTime()));
3584             }
3585         }
3586 
3587         FreeBusy(SimpleDateFormat icalParser, Date startDate, String fbdata) {
3588             this.icalParser = icalParser;
3589             if (fbdata.length() > 0) {
3590                 Calendar currentCal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
3591                 currentCal.setTime(startDate);
3592 
3593                 startBusy(fbdata.charAt(0), currentCal);
3594                 for (int i = 1; i < fbdata.length() && knownAttendee; i++) {
3595                     currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL);
3596                     char previousState = fbdata.charAt(i - 1);
3597                     char currentState = fbdata.charAt(i);
3598                     if (previousState != currentState) {
3599                         endBusy(previousState, currentCal);
3600                         startBusy(currentState, currentCal);
3601                     }
3602                 }
3603                 currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL);
3604                 endBusy(fbdata.charAt(fbdata.length() - 1), currentCal);
3605             }
3606         }
3607 
3608         /**
3609          * Append freebusy information to buffer.
3610          *
3611          * @param buffer String buffer
3612          */
3613         public void appendTo(StringBuilder buffer) {
3614             for (Map.Entry<String, StringBuilder> entry : busyMap.entrySet()) {
3615                 buffer.append("FREEBUSY;FBTYPE=").append(entry.getKey())
3616                         .append(':').append(entry.getValue()).append((char) 13).append((char) 10);
3617             }
3618         }
3619     }
3620 
3621     protected VObject vTimezone;
3622 
3623     /**
3624      * Load and return current user OWA timezone.
3625      *
3626      * @return current timezone
3627      */
3628     public VObject getVTimezone() {
3629         if (vTimezone == null) {
3630             // need to load Timezone info from OWA
3631             loadVtimezone();
3632         }
3633         return vTimezone;
3634     }
3635 
3636     protected abstract void loadVtimezone();
3637 
3638     /**
3639      * Return internal HttpClient instance
3640      *
3641      * @return http client
3642      */
3643     public HttpClient getHttpClient() {
3644         return httpClient;
3645     }
3646 }