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