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