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      * Exists condition.
1198      *
1199      * @param attributeName logical Exchange attribute name
1200      * @return condition
1201      */
1202     public abstract Condition exists(String attributeName);
1203 
1204     /**
1205      * Is true condition.
1206      *
1207      * @param attributeName logical Exchange attribute name
1208      * @return condition
1209      */
1210     public abstract Condition isTrue(String attributeName);
1211 
1212     /**
1213      * Is false condition.
1214      *
1215      * @param attributeName logical Exchange attribute name
1216      * @return condition
1217      */
1218     public abstract Condition isFalse(String attributeName);
1219 
1220     /**
1221      * Search mail and generic folders under given folder.
1222      * Exclude calendar and contacts folders
1223      *
1224      * @param folderName Exchange folder name
1225      * @param recursive  deep search if true
1226      * @return list of folders
1227      * @throws IOException on error
1228      */
1229     public List<Folder> getSubFolders(String folderName, boolean recursive, boolean wildcard) throws IOException {
1230         MultiCondition folderCondition = and();
1231         if (!Settings.getBooleanProperty("davmail.imapIncludeSpecialFolders", false)) {
1232             folderCondition.add(or(isEqualTo("folderclass", "IPF.Note"), isNull("folderclass")));
1233         }
1234         if (wildcard) {
1235             folderCondition.add(startsWith("displayname", folderName));
1236             folderName = "";
1237         }
1238         List<Folder> results = getSubFolders(folderName, folderCondition,
1239                 recursive);
1240         // need to include base folder in recursive search, except on root
1241         if (recursive && folderName.length() > 0) {
1242             results.add(getFolder(folderName));
1243         }
1244 
1245         return results;
1246     }
1247 
1248     /**
1249      * Search calendar folders under given folder.
1250      *
1251      * @param folderName Exchange folder name
1252      * @param recursive  deep search if true
1253      * @return list of folders
1254      * @throws IOException on error
1255      */
1256     public List<Folder> getSubCalendarFolders(String folderName, boolean recursive) throws IOException {
1257         return getSubFolders(folderName, isEqualTo("folderclass", "IPF.Appointment"), recursive);
1258     }
1259 
1260     /**
1261      * Search folders under given folder matching filter.
1262      *
1263      * @param folderName Exchange folder name
1264      * @param condition  search filter
1265      * @param recursive  deep search if true
1266      * @return list of folders
1267      * @throws IOException on error
1268      */
1269     public abstract List<Folder> getSubFolders(String folderName, Condition condition, boolean recursive) throws IOException;
1270 
1271     /**
1272      * Delete oldest messages in trash.
1273      * keepDelay is the number of days to keep messages in trash before delete
1274      *
1275      * @throws IOException when unable to purge messages
1276      */
1277     public void purgeOldestTrashAndSentMessages() throws IOException {
1278         int keepDelay = Settings.getIntProperty("davmail.keepDelay");
1279         if (keepDelay != 0) {
1280             purgeOldestFolderMessages(TRASH, keepDelay);
1281         }
1282         // this is a new feature, default is : do nothing
1283         int sentKeepDelay = Settings.getIntProperty("davmail.sentKeepDelay");
1284         if (sentKeepDelay != 0) {
1285             purgeOldestFolderMessages(SENT, sentKeepDelay);
1286         }
1287     }
1288 
1289     protected void purgeOldestFolderMessages(String folderPath, int keepDelay) throws IOException {
1290         Calendar cal = Calendar.getInstance();
1291         cal.add(Calendar.DAY_OF_MONTH, -keepDelay);
1292         LOGGER.debug("Delete messages in " + folderPath + " not modified since " + cal.getTime());
1293 
1294         MessageList messages = searchMessages(folderPath, UID_MESSAGE_ATTRIBUTES,
1295                 lt("lastmodified", formatSearchDate(cal.getTime())));
1296 
1297         for (Message message : messages) {
1298             message.delete();
1299         }
1300     }
1301 
1302     protected void convertResentHeader(MimeMessage mimeMessage, String headerName) throws MessagingException {
1303         String[] resentHeader = mimeMessage.getHeader("Resent-" + headerName);
1304         if (resentHeader != null) {
1305             mimeMessage.removeHeader("Resent-" + headerName);
1306             mimeMessage.removeHeader(headerName);
1307             for (String value : resentHeader) {
1308                 mimeMessage.addHeader(headerName, value);
1309             }
1310         }
1311     }
1312 
1313     protected String lastSentMessageId;
1314 
1315     /**
1316      * Send message in reader to recipients.
1317      * Detect visible recipients in message body to determine bcc recipients
1318      *
1319      * @param rcptToRecipients recipients list
1320      * @param mimeMessage      mime message
1321      * @throws IOException        on error
1322      * @throws MessagingException on error
1323      */
1324     public void sendMessage(List<String> rcptToRecipients, MimeMessage mimeMessage) throws IOException, MessagingException {
1325         // detect duplicate send command
1326         String messageId = mimeMessage.getMessageID();
1327         if (lastSentMessageId != null && lastSentMessageId.equals(messageId)) {
1328             LOGGER.debug("Dropping message id " + messageId + ": already sent");
1329             return;
1330         }
1331         lastSentMessageId = messageId;
1332 
1333         convertResentHeader(mimeMessage, "From");
1334         convertResentHeader(mimeMessage, "To");
1335         convertResentHeader(mimeMessage, "Cc");
1336         convertResentHeader(mimeMessage, "Bcc");
1337         convertResentHeader(mimeMessage, "Message-Id");
1338 
1339         // do not allow send as another user on Exchange 2003
1340         if ("Exchange2003".equals(serverVersion) || Settings.getBooleanProperty("davmail.smtpStripFrom", false)) {
1341             mimeMessage.removeHeader("From");
1342         }
1343 
1344         // remove visible recipients from list
1345         Set<String> visibleRecipients = new HashSet<String>();
1346         List<InternetAddress> recipients = getAllRecipients(mimeMessage);
1347         for (InternetAddress address : recipients) {
1348             visibleRecipients.add((address.getAddress().toLowerCase()));
1349         }
1350         for (String recipient : rcptToRecipients) {
1351             if (!visibleRecipients.contains(recipient.toLowerCase())) {
1352                 mimeMessage.addRecipient(javax.mail.Message.RecipientType.BCC, new InternetAddress(recipient));
1353             }
1354         }
1355         sendMessage(mimeMessage);
1356 
1357     }
1358 
1359     static final String[] RECIPIENT_HEADERS = {"to", "cc", "bcc"};
1360 
1361     protected List<InternetAddress> getAllRecipients(MimeMessage mimeMessage) throws MessagingException {
1362         List<InternetAddress> recipientList = new ArrayList<InternetAddress>();
1363         for (String recipientHeader : RECIPIENT_HEADERS) {
1364             final String recipientHeaderValue = mimeMessage.getHeader(recipientHeader, ",");
1365             if (recipientHeaderValue != null) {
1366                 // parse headers in non strict mode
1367                 recipientList.addAll(Arrays.asList(InternetAddress.parseHeader(recipientHeaderValue, false)));
1368             }
1369 
1370         }
1371         return recipientList;
1372     }
1373 
1374     /**
1375      * Send Mime message.
1376      *
1377      * @param mimeMessage MIME message
1378      * @throws IOException        on error
1379      * @throws MessagingException on error
1380      */
1381     public abstract void sendMessage(MimeMessage mimeMessage) throws IOException, MessagingException;
1382 
1383     /**
1384      * Get folder object.
1385      * Folder name can be logical names INBOX, Drafts, Trash or calendar,
1386      * or a path relative to user base folder or absolute path.
1387      *
1388      * @param folderPath folder path
1389      * @return Folder object
1390      * @throws IOException on error
1391      */
1392     public ExchangeSession.Folder getFolder(String folderPath) throws IOException {
1393         Folder folder = internalGetFolder(folderPath);
1394         if (isMainCalendar(folderPath)) {
1395             Folder taskFolder = internalGetFolder(TASKS);
1396             folder.ctag += taskFolder.ctag;
1397         }
1398         return folder;
1399     }
1400 
1401     protected abstract Folder internalGetFolder(String folderName) throws IOException;
1402 
1403     /**
1404      * Check folder ctag and reload messages as needed.
1405      *
1406      * @param currentFolder current folder
1407      * @return true if folder changed
1408      * @throws IOException on error
1409      */
1410     public boolean refreshFolder(Folder currentFolder) throws IOException {
1411         Folder newFolder = getFolder(currentFolder.folderPath);
1412         if (currentFolder.ctag == null || !currentFolder.ctag.equals(newFolder.ctag)
1413                 // ctag stamp is limited to second, check message count
1414                 || !(currentFolder.count == newFolder.count)
1415                 ) {
1416             if (LOGGER.isDebugEnabled()) {
1417                 LOGGER.debug("Contenttag or count changed on " + currentFolder.folderPath +
1418                         " ctag: " + currentFolder.ctag + " => " + newFolder.ctag +
1419                         " count: " + currentFolder.count + " => " + newFolder.count
1420                         + ", reloading messages");
1421             }
1422             currentFolder.hasChildren = newFolder.hasChildren;
1423             currentFolder.noInferiors = newFolder.noInferiors;
1424             currentFolder.unreadCount = newFolder.unreadCount;
1425             currentFolder.ctag = newFolder.ctag;
1426             currentFolder.etag = newFolder.etag;
1427             if (newFolder.uidNext > currentFolder.uidNext) {
1428                 currentFolder.uidNext = newFolder.uidNext;
1429             }
1430             currentFolder.loadMessages();
1431             return true;
1432         } else {
1433             return false;
1434         }
1435     }
1436 
1437     /**
1438      * Create Exchange message folder.
1439      *
1440      * @param folderName logical folder name
1441      * @return status
1442      * @throws IOException on error
1443      */
1444     public int createMessageFolder(String folderName) throws IOException {
1445         return createFolder(folderName, "IPF.Note", null);
1446     }
1447 
1448     /**
1449      * Create Exchange calendar 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 createCalendarFolder(String folderName, Map<String, String> properties) throws IOException {
1457         return createFolder(folderName, "IPF.Appointment", properties);
1458     }
1459 
1460     /**
1461      * Create Exchange contact folder.
1462      *
1463      * @param folderName logical folder name
1464      * @param properties folder properties
1465      * @return status
1466      * @throws IOException on error
1467      */
1468     public int createContactFolder(String folderName, Map<String, String> properties) throws IOException {
1469         return createFolder(folderName, "IPF.Contact", properties);
1470     }
1471 
1472     /**
1473      * Create Exchange folder with given folder class.
1474      *
1475      * @param folderName  logical folder name
1476      * @param folderClass folder class
1477      * @param properties  folder properties
1478      * @return status
1479      * @throws IOException on error
1480      */
1481     public abstract int createFolder(String folderName, String folderClass, Map<String, String> properties) throws IOException;
1482 
1483     /**
1484      * Update Exchange folder properties.
1485      *
1486      * @param folderName logical folder name
1487      * @param properties folder properties
1488      * @return status
1489      * @throws IOException on error
1490      */
1491     public abstract int updateFolder(String folderName, Map<String, String> properties) throws IOException;
1492 
1493     /**
1494      * Delete Exchange folder.
1495      *
1496      * @param folderName logical folder name
1497      * @throws IOException on error
1498      */
1499     public abstract void deleteFolder(String folderName) throws IOException;
1500 
1501     /**
1502      * Copy message to target folder
1503      *
1504      * @param message      Exchange message
1505      * @param targetFolder target folder
1506      * @throws IOException on error
1507      */
1508     public abstract void copyMessage(Message message, String targetFolder) throws IOException;
1509 
1510     public void copyMessages(List<Message> messages, String targetFolder) throws IOException {
1511         for (Message message: messages) {
1512             copyMessage(message, targetFolder);
1513         }
1514     }
1515 
1516 
1517     /**
1518      * Move message to target folder
1519      *
1520      * @param message      Exchange message
1521      * @param targetFolder target folder
1522      * @throws IOException on error
1523      */
1524     public abstract void moveMessage(Message message, String targetFolder) throws IOException;
1525 
1526     public void moveMessages(List<Message> messages, String targetFolder) throws IOException {
1527         for (Message message: messages) {
1528             moveMessage(message, targetFolder);
1529         }
1530     }
1531 
1532     /**
1533      * Move folder to target name.
1534      *
1535      * @param folderName current folder name/path
1536      * @param targetName target folder name/path
1537      * @throws IOException on error
1538      */
1539     public abstract void moveFolder(String folderName, String targetName) throws IOException;
1540 
1541     /**
1542      * Move item from source path to target path.
1543      *
1544      * @param sourcePath item source path
1545      * @param targetPath item target path
1546      * @throws IOException on error
1547      */
1548     public abstract void moveItem(String sourcePath, String targetPath) throws IOException;
1549 
1550     protected abstract void moveToTrash(Message message) throws IOException;
1551 
1552     /**
1553      * Convert keyword value to IMAP flag.
1554      *
1555      * @param value keyword value
1556      * @return IMAP flag
1557      */
1558     public String convertKeywordToFlag(String value) {
1559         // first test for keyword in settings
1560         Properties flagSettings = Settings.getSubProperties("davmail.imapFlags");
1561         Enumeration flagSettingsEnum = flagSettings.propertyNames();
1562         while (flagSettingsEnum.hasMoreElements()) {
1563             String key = (String) flagSettingsEnum.nextElement();
1564             if (value.equalsIgnoreCase(flagSettings.getProperty(key))) {
1565                 return key;
1566             }
1567         }
1568 
1569         ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags");
1570         Enumeration<String> flagBundleEnum = flagBundle.getKeys();
1571         while (flagBundleEnum.hasMoreElements()) {
1572             String key = flagBundleEnum.nextElement();
1573             if (value.equalsIgnoreCase(flagBundle.getString(key))) {
1574                 return key;
1575             }
1576         }
1577 
1578         // fall back to raw value
1579         return value;
1580     }
1581 
1582     /**
1583      * Convert IMAP flag to keyword value.
1584      *
1585      * @param value IMAP flag
1586      * @return keyword value
1587      */
1588     public String convertFlagToKeyword(String value) {
1589         // first test for flag in settings
1590         Properties flagSettings = Settings.getSubProperties("davmail.imapFlags");
1591         // case insensitive lookup
1592         for (String key : flagSettings.stringPropertyNames()) {
1593             if (key.equalsIgnoreCase(value)) {
1594                 return flagSettings.getProperty(key);
1595             }
1596         }
1597 
1598         // fall back to predefined flags
1599         ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags");
1600         for (String key : flagBundle.keySet()) {
1601             if (key.equalsIgnoreCase(value)) {
1602                 return flagBundle.getString(key);
1603             }
1604         }
1605 
1606         // fall back to raw value
1607         return value;
1608     }
1609 
1610     /**
1611      * Convert IMAP flags to keyword value.
1612      *
1613      * @param flags IMAP flags
1614      * @return keyword value
1615      */
1616     public String convertFlagsToKeywords(HashSet<String> flags) {
1617         HashSet<String> keywordSet = new HashSet<String>();
1618         for (String flag : flags) {
1619             keywordSet.add(convertFlagToKeyword(flag));
1620         }
1621         return StringUtil.join(keywordSet, ",");
1622     }
1623 
1624     /**
1625      * Exchange folder with IMAP properties
1626      */
1627     public class Folder {
1628         /**
1629          * Logical (IMAP) folder path.
1630          */
1631         public String folderPath;
1632 
1633         /**
1634          * Display Name.
1635          */
1636         public String displayName;
1637         /**
1638          * Folder class (PR_CONTAINER_CLASS).
1639          */
1640         public String folderClass;
1641         /**
1642          * Folder message count.
1643          */
1644         public int count;
1645         /**
1646          * Folder unread message count.
1647          */
1648         public int unreadCount;
1649         /**
1650          * true if folder has subfolders (DAV:hassubs).
1651          */
1652         public boolean hasChildren;
1653         /**
1654          * true if folder has no subfolders (DAV:nosubs).
1655          */
1656         public boolean noInferiors;
1657         /**
1658          * Folder content tag (to detect folder content changes).
1659          */
1660         public String ctag;
1661         /**
1662          * Folder etag (to detect folder object changes).
1663          */
1664         public String etag;
1665         /**
1666          * Next IMAP uid
1667          */
1668         public long uidNext;
1669         /**
1670          * recent count
1671          */
1672         public int recent;
1673 
1674         /**
1675          * Folder message list, empty before loadMessages call.
1676          */
1677         public ExchangeSession.MessageList messages;
1678         /**
1679          * Permanent uid (PR_SEARCH_KEY) to IMAP UID map.
1680          */
1681         private final HashMap<String, Long> permanentUrlToImapUidMap = new HashMap<String, Long>();
1682 
1683         /**
1684          * Get IMAP folder flags.
1685          *
1686          * @return folder flags in IMAP format
1687          */
1688         public String getFlags() {
1689             if (noInferiors) {
1690                 return "\\NoInferiors";
1691             } else if (hasChildren) {
1692                 return "\\HasChildren";
1693             } else {
1694                 return "\\HasNoChildren";
1695             }
1696         }
1697 
1698         /**
1699          * Load folder messages.
1700          *
1701          * @throws IOException on error
1702          */
1703         public void loadMessages() throws IOException {
1704             messages = ExchangeSession.this.searchMessages(folderPath, null);
1705             fixUids(messages);
1706             recent = 0;
1707             for (Message message : messages) {
1708                 if (message.recent) {
1709                     recent++;
1710                 }
1711             }
1712             long computedUidNext = 1;
1713             if (!messages.isEmpty()) {
1714                 computedUidNext = messages.get(messages.size() - 1).getImapUid() + 1;
1715             }
1716             if (computedUidNext > uidNext) {
1717                 uidNext = computedUidNext;
1718             }
1719         }
1720 
1721         /**
1722          * Search messages in folder matching query.
1723          *
1724          * @param condition search query
1725          * @return message list
1726          * @throws IOException on error
1727          */
1728         public MessageList searchMessages(Condition condition) throws IOException {
1729             MessageList localMessages = ExchangeSession.this.searchMessages(folderPath, condition);
1730             fixUids(localMessages);
1731             return localMessages;
1732         }
1733 
1734         /**
1735          * Restore previous uids changed by a PROPPATCH (flag change).
1736          *
1737          * @param messages message list
1738          */
1739         protected void fixUids(MessageList messages) {
1740             boolean sortNeeded = false;
1741             for (Message message : messages) {
1742                 if (permanentUrlToImapUidMap.containsKey(message.getPermanentId())) {
1743                     long previousUid = permanentUrlToImapUidMap.get(message.getPermanentId());
1744                     if (message.getImapUid() != previousUid) {
1745                         LOGGER.debug("Restoring IMAP uid " + message.getImapUid() + " -> " + previousUid + " for message " + message.getPermanentId());
1746                         message.setImapUid(previousUid);
1747                         sortNeeded = true;
1748                     }
1749                 } else {
1750                     // add message to uid map
1751                     permanentUrlToImapUidMap.put(message.getPermanentId(), message.getImapUid());
1752                 }
1753             }
1754             if (sortNeeded) {
1755                 Collections.sort(messages);
1756             }
1757         }
1758 
1759         /**
1760          * Folder message count.
1761          *
1762          * @return message count
1763          */
1764         public int count() {
1765             if (messages == null) {
1766                 return count;
1767             } else {
1768                 return messages.size();
1769             }
1770         }
1771 
1772         /**
1773          * Compute IMAP uidnext.
1774          *
1775          * @return max(messageuids)+1
1776          */
1777         public long getUidNext() {
1778             return uidNext;
1779         }
1780 
1781         /**
1782          * Get message at index.
1783          *
1784          * @param index message index
1785          * @return message
1786          */
1787         public Message get(int index) {
1788             return messages.get(index);
1789         }
1790 
1791         /**
1792          * Get current folder messages imap uids and flags
1793          *
1794          * @return imap uid list
1795          */
1796         public TreeMap<Long, String> getImapFlagMap() {
1797             TreeMap<Long, String> imapFlagMap = new TreeMap<Long, String>();
1798             for (ExchangeSession.Message message : messages) {
1799                 imapFlagMap.put(message.getImapUid(), message.getImapFlags());
1800             }
1801             return imapFlagMap;
1802         }
1803 
1804         /**
1805          * Calendar folder flag.
1806          *
1807          * @return true if this is a calendar folder
1808          */
1809         public boolean isCalendar() {
1810             return "IPF.Appointment".equals(folderClass);
1811         }
1812 
1813         /**
1814          * Contact folder flag.
1815          *
1816          * @return true if this is a calendar folder
1817          */
1818         public boolean isContact() {
1819             return "IPF.Contact".equals(folderClass);
1820         }
1821 
1822         /**
1823          * Task folder flag.
1824          *
1825          * @return true if this is a task folder
1826          */
1827         public boolean isTask() {
1828             return "IPF.Task".equals(folderClass);
1829         }
1830 
1831         /**
1832          * drop cached message
1833          */
1834         public void clearCache() {
1835             messages.cachedMimeContent = null;
1836             messages.cachedMimeMessage = null;
1837             messages.cachedMessageImapUid = 0;
1838         }
1839     }
1840 
1841     /**
1842      * Exchange message.
1843      */
1844     public abstract class Message implements Comparable<Message> {
1845         /**
1846          * enclosing message list
1847          */
1848         public MessageList messageList;
1849         /**
1850          * Message url.
1851          */
1852         public String messageUrl;
1853         /**
1854          * Message permanent url (does not change on message move).
1855          */
1856         public String permanentUrl;
1857         /**
1858          * Message uid.
1859          */
1860         public String uid;
1861         /**
1862          * Message content class.
1863          */
1864         public String contentClass;
1865         /**
1866          * Message keywords (categories).
1867          */
1868         public String keywords;
1869         /**
1870          * Message IMAP uid, unique in folder (x0e230003).
1871          */
1872         public long imapUid;
1873         /**
1874          * MAPI message size.
1875          */
1876         public int size;
1877         /**
1878          * Message date (urn:schemas:mailheader:date).
1879          */
1880         public String date;
1881 
1882         /**
1883          * Message flag: read.
1884          */
1885         public boolean read;
1886         /**
1887          * Message flag: deleted.
1888          */
1889         public boolean deleted;
1890         /**
1891          * Message flag: junk.
1892          */
1893         public boolean junk;
1894         /**
1895          * Message flag: flagged.
1896          */
1897         public boolean flagged;
1898         /**
1899          * Message flag: recent.
1900          */
1901         public boolean recent;
1902         /**
1903          * Message flag: draft.
1904          */
1905         public boolean draft;
1906         /**
1907          * Message flag: answered.
1908          */
1909         public boolean answered;
1910         /**
1911          * Message flag: fowarded.
1912          */
1913         public boolean forwarded;
1914 
1915         /**
1916          * Unparsed message content.
1917          */
1918         protected byte[] mimeContent;
1919 
1920         /**
1921          * Message content parsed in a MIME message.
1922          */
1923         protected MimeMessage mimeMessage;
1924 
1925         /**
1926          * Get permanent message id.
1927          * permanentUrl over WebDav or IitemId over EWS
1928          *
1929          * @return permanent id
1930          */
1931         public abstract String getPermanentId();
1932 
1933         /**
1934          * IMAP uid , unique in folder (x0e230003)
1935          *
1936          * @return IMAP uid
1937          */
1938         public long getImapUid() {
1939             return imapUid;
1940         }
1941 
1942         /**
1943          * Set IMAP uid.
1944          *
1945          * @param imapUid new uid
1946          */
1947         public void setImapUid(long imapUid) {
1948             this.imapUid = imapUid;
1949         }
1950 
1951         /**
1952          * Exchange uid.
1953          *
1954          * @return uid
1955          */
1956         public String getUid() {
1957             return uid;
1958         }
1959 
1960         /**
1961          * Return message flags in IMAP format.
1962          *
1963          * @return IMAP flags
1964          */
1965         public String getImapFlags() {
1966             StringBuilder buffer = new StringBuilder();
1967             if (read) {
1968                 buffer.append("\\Seen ");
1969             }
1970             if (deleted) {
1971                 buffer.append("\\Deleted ");
1972             }
1973             if (recent) {
1974                 buffer.append("\\Recent ");
1975             }
1976             if (flagged) {
1977                 buffer.append("\\Flagged ");
1978             }
1979             if (junk) {
1980                 buffer.append("Junk ");
1981             }
1982             if (draft) {
1983                 buffer.append("\\Draft ");
1984             }
1985             if (answered) {
1986                 buffer.append("\\Answered ");
1987             }
1988             if (forwarded) {
1989                 buffer.append("$Forwarded ");
1990             }
1991             if (keywords != null) {
1992                 for (String keyword : keywords.split(",")) {
1993                     buffer.append(convertKeywordToFlag(keyword)).append(" ");
1994                 }
1995             }
1996             return buffer.toString().trim();
1997         }
1998 
1999         /**
2000          * Load message content in a Mime message
2001          *
2002          * @throws IOException        on error
2003          * @throws MessagingException on error
2004          */
2005         public void loadMimeMessage() throws IOException, MessagingException {
2006             if (mimeMessage == null) {
2007                 // try to get message content from cache
2008                 if (this.imapUid == messageList.cachedMessageImapUid) {
2009                     mimeContent = messageList.cachedMimeContent;
2010                     mimeMessage = messageList.cachedMimeMessage;
2011                     LOGGER.debug("Got message content for " + imapUid + " from cache");
2012                 } else {
2013                     // load and parse message
2014                     mimeContent = getContent(this);
2015                     mimeMessage = new MimeMessage(null, new SharedByteArrayInputStream(mimeContent));
2016                     // workaround for Exchange 2003 ActiveSync bug
2017                     if (mimeMessage.getHeader("MAIL FROM") != null) {
2018                         // find start of actual message
2019                         byte[] mimeContentCopy = new byte[((SharedByteArrayInputStream) mimeMessage.getRawInputStream()).available()];
2020                         int offset = mimeContent.length - mimeContentCopy.length;
2021                         // remove unwanted header
2022                         System.arraycopy(mimeContent, offset, mimeContentCopy, 0, mimeContentCopy.length);
2023                         mimeContent = mimeContentCopy;
2024                         mimeMessage = new MimeMessage(null, new SharedByteArrayInputStream(mimeContent));
2025                         System.out.println(new String(mimeContent));
2026                     }
2027                     LOGGER.debug("Downloaded full message content for IMAP UID " + imapUid + " (" + mimeContent.length + " bytes)");
2028                 }
2029             }
2030         }
2031 
2032         /**
2033          * Get message content as a Mime message.
2034          *
2035          * @return mime message
2036          * @throws IOException        on error
2037          * @throws MessagingException on error
2038          */
2039         public MimeMessage getMimeMessage() throws IOException, MessagingException {
2040             loadMimeMessage();
2041             return mimeMessage;
2042         }
2043 
2044         public Enumeration getMatchingHeaderLinesFromHeaders(String[] headerNames) throws MessagingException, IOException {
2045             Enumeration result = null;
2046             if (mimeMessage == null) {
2047                 // message not loaded, try to get headers only
2048                 InputStream headers = getMimeHeaders();
2049                 if (headers != null) {
2050                     InternetHeaders internetHeaders = new InternetHeaders(headers);
2051                     if (internetHeaders.getHeader("Subject") == null) {
2052                         // invalid header content
2053                         return null;
2054                     }
2055                     if (headerNames == null) {
2056                         result = internetHeaders.getAllHeaderLines();
2057                     } else {
2058                         result = internetHeaders.getMatchingHeaderLines(headerNames);
2059                     }
2060                 }
2061             }
2062             return result;
2063         }
2064 
2065         public Enumeration getMatchingHeaderLines(String[] headerNames) throws MessagingException, IOException {
2066             Enumeration result = getMatchingHeaderLinesFromHeaders(headerNames);
2067             if (result == null) {
2068                 if (headerNames == null) {
2069                     result = getMimeMessage().getAllHeaderLines();
2070                 } else {
2071                     result = getMimeMessage().getMatchingHeaderLines(headerNames);
2072                 }
2073 
2074             }
2075             return result;
2076         }
2077 
2078         protected abstract InputStream getMimeHeaders();
2079 
2080         /**
2081          * Get message body size.
2082          *
2083          * @return mime message size
2084          * @throws IOException        on error
2085          * @throws MessagingException on error
2086          */
2087         public int getMimeMessageSize() throws IOException, MessagingException {
2088             loadMimeMessage();
2089             return mimeContent.length;
2090         }
2091 
2092         /**
2093          * Get message body input stream.
2094          *
2095          * @return mime message InputStream
2096          * @throws IOException        on error
2097          * @throws MessagingException on error
2098          */
2099         public InputStream getRawInputStream() throws IOException, MessagingException {
2100             loadMimeMessage();
2101             return new SharedByteArrayInputStream(mimeContent);
2102         }
2103 
2104 
2105         /**
2106          * Drop mime message to avoid keeping message content in memory,
2107          * keep a single message in MessageList cache to handle chunked fetch.
2108          */
2109         public void dropMimeMessage() {
2110             // update single message cache
2111             if (mimeMessage != null) {
2112                 messageList.cachedMessageImapUid = imapUid;
2113                 messageList.cachedMimeContent = mimeContent;
2114                 messageList.cachedMimeMessage = mimeMessage;
2115             }
2116             // drop curent message body to save memory
2117             mimeMessage = null;
2118             mimeContent = null;
2119         }
2120 
2121         public boolean isLoaded() {
2122             // check and retrieve cached content
2123             if (imapUid == messageList.cachedMessageImapUid) {
2124                 mimeContent = messageList.cachedMimeContent;
2125                 mimeMessage = messageList.cachedMimeMessage;
2126             }
2127             return mimeMessage != null;
2128         }
2129 
2130         /**
2131          * Delete message.
2132          *
2133          * @throws IOException on error
2134          */
2135         public void delete() throws IOException {
2136             deleteMessage(this);
2137         }
2138 
2139         /**
2140          * Move message to trash, mark message read.
2141          *
2142          * @throws IOException on error
2143          */
2144         public void moveToTrash() throws IOException {
2145             markRead();
2146 
2147             ExchangeSession.this.moveToTrash(this);
2148         }
2149 
2150         /**
2151          * Mark message as read.
2152          *
2153          * @throws IOException on error
2154          */
2155         public void markRead() throws IOException {
2156             HashMap<String, String> properties = new HashMap<String, String>();
2157             properties.put("read", "1");
2158             updateMessage(this, properties);
2159         }
2160 
2161         /**
2162          * Comparator to sort messages by IMAP uid
2163          *
2164          * @param message other message
2165          * @return imapUid comparison result
2166          */
2167         public int compareTo(Message message) {
2168             long compareValue = (imapUid - message.imapUid);
2169             if (compareValue > 0) {
2170                 return 1;
2171             } else if (compareValue < 0) {
2172                 return -1;
2173             } else {
2174                 return 0;
2175             }
2176         }
2177 
2178         /**
2179          * Override equals, compare IMAP uids
2180          *
2181          * @param message other message
2182          * @return true if IMAP uids are equal
2183          */
2184         @Override
2185         public boolean equals(Object message) {
2186             return message instanceof Message && imapUid == ((Message) message).imapUid;
2187         }
2188 
2189         /**
2190          * Override hashCode, return imapUid hashcode.
2191          *
2192          * @return imapUid hashcode
2193          */
2194         @Override
2195         public int hashCode() {
2196             return (int) (imapUid ^ (imapUid >>> 32));
2197         }
2198 
2199         public String removeFlag(String flag) {
2200             if (keywords != null) {
2201                 final String exchangeFlag = convertFlagToKeyword(flag);
2202                 Set<String> keywordSet = new HashSet<String>();
2203                 String[] keywordArray = keywords.split(",");
2204                 for (String value : keywordArray) {
2205                     if (!value.equalsIgnoreCase(exchangeFlag)) {
2206                         keywordSet.add(value);
2207                     }
2208                 }
2209                 keywords = StringUtil.join(keywordSet, ",");
2210             }
2211             return keywords;
2212         }
2213 
2214         public String addFlag(String flag) {
2215             final String exchangeFlag = convertFlagToKeyword(flag);
2216             HashSet<String> keywordSet = new HashSet<String>();
2217             boolean hasFlag = false;
2218             if (keywords != null) {
2219                 String[] keywordArray = keywords.split(",");
2220                 for (String value : keywordArray) {
2221                     keywordSet.add(value);
2222                     if (value.equalsIgnoreCase(exchangeFlag)) {
2223                         hasFlag = true;
2224                     }
2225                 }
2226             }
2227             if (!hasFlag) {
2228                 keywordSet.add(exchangeFlag);
2229             }
2230             keywords = StringUtil.join(keywordSet, ",");
2231             return keywords;
2232         }
2233 
2234         public String setFlags(HashSet<String> flags) {
2235             keywords = convertFlagsToKeywords(flags);
2236             return keywords;
2237         }
2238 
2239     }
2240 
2241     /**
2242      * Message list, includes a single messsage cache
2243      */
2244     public static class MessageList extends ArrayList<Message> {
2245         /**
2246          * Cached message content parsed in a MIME message.
2247          */
2248         protected transient MimeMessage cachedMimeMessage;
2249         /**
2250          * Cached message uid.
2251          */
2252         protected transient long cachedMessageImapUid;
2253         /**
2254          * Cached unparsed message
2255          */
2256         protected transient byte[] cachedMimeContent;
2257 
2258     }
2259 
2260     /**
2261      * Generic folder item.
2262      */
2263     public abstract static class Item extends HashMap<String, String> {
2264         protected String folderPath;
2265         protected String itemName;
2266         protected String permanentUrl;
2267         /**
2268          * Display name.
2269          */
2270         public String displayName;
2271         /**
2272          * item etag
2273          */
2274         public String etag;
2275         protected String noneMatch;
2276 
2277         /**
2278          * Build item instance.
2279          *
2280          * @param folderPath folder path
2281          * @param itemName   item name class
2282          * @param etag       item etag
2283          * @param noneMatch  none match flag
2284          */
2285         public Item(String folderPath, String itemName, String etag, String noneMatch) {
2286             this.folderPath = folderPath;
2287             this.itemName = itemName;
2288             this.etag = etag;
2289             this.noneMatch = noneMatch;
2290         }
2291 
2292         /**
2293          * Default constructor.
2294          */
2295         protected Item() {
2296         }
2297 
2298         /**
2299          * Return item content type
2300          *
2301          * @return content type
2302          */
2303         public abstract String getContentType();
2304 
2305         /**
2306          * Retrieve item body from Exchange
2307          *
2308          * @return item body
2309          * @throws HttpException on error
2310          */
2311         public abstract String getBody() throws IOException;
2312 
2313         /**
2314          * Get event name (file name part in URL).
2315          *
2316          * @return event name
2317          */
2318         public String getName() {
2319             return itemName;
2320         }
2321 
2322         /**
2323          * Get event etag (last change tag).
2324          *
2325          * @return event etag
2326          */
2327         public String getEtag() {
2328             return etag;
2329         }
2330 
2331         /**
2332          * Set item href.
2333          *
2334          * @param href item href
2335          */
2336         public void setHref(String href) {
2337             int index = href.lastIndexOf('/');
2338             if (index >= 0) {
2339                 folderPath = href.substring(0, index);
2340                 itemName = href.substring(index + 1);
2341             } else {
2342                 throw new IllegalArgumentException(href);
2343             }
2344         }
2345 
2346         /**
2347          * Return item href.
2348          *
2349          * @return item href
2350          */
2351         public String getHref() {
2352             return folderPath + '/' + itemName;
2353         }
2354 
2355         public void setItemName(String itemName) {
2356             this.itemName = itemName;
2357         }
2358     }
2359 
2360     /**
2361      * Contact object
2362      */
2363     public abstract class Contact extends Item {
2364 
2365         /**
2366          * @inheritDoc
2367          */
2368         public Contact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) {
2369             super(folderPath, itemName.endsWith(".vcf") ? itemName.substring(0, itemName.length() - 3) + "EML" : itemName, etag, noneMatch);
2370             this.putAll(properties);
2371         }
2372 
2373         /**
2374          * @inheritDoc
2375          */
2376         protected Contact() {
2377         }
2378 
2379         /**
2380          * Convert EML extension to vcf.
2381          *
2382          * @return item name
2383          */
2384         @Override
2385         public String getName() {
2386             String name = super.getName();
2387             if (name.endsWith(".EML")) {
2388                 name = name.substring(0, name.length() - 3) + "vcf";
2389             }
2390             return name;
2391         }
2392 
2393         /**
2394          * Set contact name
2395          *
2396          * @param name contact name
2397          */
2398         public void setName(String name) {
2399             this.itemName = name;
2400         }
2401 
2402         /**
2403          * Compute vcard uid from name.
2404          *
2405          * @return uid
2406          * @throws URIException on error
2407          */
2408         protected String getUid() throws URIException {
2409             String uid = getName();
2410             int dotIndex = uid.lastIndexOf('.');
2411             if (dotIndex > 0) {
2412                 uid = uid.substring(0, dotIndex);
2413             }
2414             return URIUtil.encodePath(uid);
2415         }
2416 
2417         @Override
2418         public String getContentType() {
2419             return "text/vcard";
2420         }
2421 
2422 
2423         @Override
2424         public String getBody() throws HttpException {
2425             // build RFC 2426 VCard from contact information
2426             VCardWriter writer = new VCardWriter();
2427             writer.startCard();
2428             writer.appendProperty("UID", getUid());
2429             // common name
2430             writer.appendProperty("FN", get("cn"));
2431             // RFC 2426: Family Name, Given Name, Additional Names, Honorific Prefixes, and Honorific Suffixes
2432             writer.appendProperty("N", get("sn"), get("givenName"), get("middlename"), get("personaltitle"), get("namesuffix"));
2433 
2434             writer.appendProperty("TEL;TYPE=cell", get("mobile"));
2435             writer.appendProperty("TEL;TYPE=work", get("telephoneNumber"));
2436             writer.appendProperty("TEL;TYPE=home", get("homePhone"));
2437             writer.appendProperty("TEL;TYPE=fax", get("facsimiletelephonenumber"));
2438             writer.appendProperty("TEL;TYPE=pager", get("pager"));
2439             writer.appendProperty("TEL;TYPE=car", get("othermobile"));
2440             writer.appendProperty("TEL;TYPE=home,fax", get("homefax"));
2441             writer.appendProperty("TEL;TYPE=isdn", get("internationalisdnnumber"));
2442             writer.appendProperty("TEL;TYPE=msg", get("otherTelephone"));
2443 
2444             // The structured type value corresponds, in sequence, to the post office box; the extended address;
2445             // the street address; the locality (e.g., city); the region (e.g., state or province);
2446             // the postal code; the country name
2447             writer.appendProperty("ADR;TYPE=home",
2448                     get("homepostofficebox"), null, get("homeStreet"), get("homeCity"), get("homeState"), get("homePostalCode"), get("homeCountry"));
2449             writer.appendProperty("ADR;TYPE=work",
2450                     get("postofficebox"), get("roomnumber"), get("street"), get("l"), get("st"), get("postalcode"), get("co"));
2451             writer.appendProperty("ADR;TYPE=other",
2452                     get("otherpostofficebox"), null, get("otherstreet"), get("othercity"), get("otherstate"), get("otherpostalcode"), get("othercountry"));
2453 
2454             writer.appendProperty("EMAIL;TYPE=work", get("smtpemail1"));
2455             writer.appendProperty("EMAIL;TYPE=home", get("smtpemail2"));
2456             writer.appendProperty("EMAIL;TYPE=other", get("smtpemail3"));
2457 
2458             writer.appendProperty("ORG", get("o"), get("department"));
2459             writer.appendProperty("URL;TYPE=work", get("businesshomepage"));
2460             writer.appendProperty("URL;TYPE=home", get("personalHomePage"));
2461             writer.appendProperty("TITLE", get("title"));
2462             writer.appendProperty("NOTE", get("description"));
2463 
2464             writer.appendProperty("CUSTOM1", get("extensionattribute1"));
2465             writer.appendProperty("CUSTOM2", get("extensionattribute2"));
2466             writer.appendProperty("CUSTOM3", get("extensionattribute3"));
2467             writer.appendProperty("CUSTOM4", get("extensionattribute4"));
2468 
2469             writer.appendProperty("ROLE", get("profession"));
2470             writer.appendProperty("NICKNAME", get("nickname"));
2471             writer.appendProperty("X-AIM", get("im"));
2472 
2473             writer.appendProperty("BDAY", convertZuluDateToBday(get("bday")));
2474             writer.appendProperty("ANNIVERSARY", convertZuluDateToBday(get("anniversary")));
2475 
2476             String gender = get("gender");
2477             if ("1".equals(gender)) {
2478                 writer.appendProperty("SEX", "2");
2479             } else if ("2".equals(gender)) {
2480                 writer.appendProperty("SEX", "1");
2481             }
2482 
2483             writer.appendProperty("CATEGORIES", get("keywords"));
2484 
2485             writer.appendProperty("FBURL", get("fburl"));
2486 
2487             if ("1".equals(get("private"))) {
2488                 writer.appendProperty("CLASS", "PRIVATE");
2489             }
2490 
2491             writer.appendProperty("X-ASSISTANT", get("secretarycn"));
2492             writer.appendProperty("X-MANAGER", get("manager"));
2493             writer.appendProperty("X-SPOUSE", get("spousecn"));
2494 
2495             writer.appendProperty("REV", get("lastmodified"));
2496 
2497             if ("true".equals(get("haspicture"))) {
2498                 try {
2499                     ContactPhoto contactPhoto = getContactPhoto(this);
2500                     writer.writeLine("PHOTO;BASE64;TYPE=\"" + contactPhoto.contentType + "\";ENCODING=\"b\":");
2501                     writer.writeLine(contactPhoto.content, true);
2502                 } catch (IOException e) {
2503                     LOGGER.warn("Unable to get photo from contact " + this.get("cn"));
2504                 }
2505             }
2506 
2507             writer.endCard();
2508             return writer.toString();
2509         }
2510 
2511     }
2512 
2513     /**
2514      * Calendar event object.
2515      */
2516     public abstract class Event extends Item {
2517         protected String contentClass;
2518         protected String subject;
2519         protected VCalendar vCalendar;
2520 
2521         /**
2522          * @inheritDoc
2523          */
2524         public Event(String folderPath, String itemName, String contentClass, String itemBody, String etag, String noneMatch) throws IOException {
2525             super(folderPath, itemName, etag, noneMatch);
2526             this.contentClass = contentClass;
2527             fixICS(itemBody.getBytes("UTF-8"), false);
2528             // fix task item name
2529             if (vCalendar.isTodo() && this.itemName.endsWith(".ics")) {
2530                 this.itemName = itemName.substring(0, itemName.length() - 3) + "EML";
2531             }
2532         }
2533 
2534         /**
2535          * @inheritDoc
2536          */
2537         protected Event() {
2538         }
2539 
2540         @Override
2541         public String getContentType() {
2542             return "text/calendar;charset=UTF-8";
2543         }
2544 
2545         @Override
2546         public String getBody() throws IOException {
2547             if (vCalendar == null) {
2548                 fixICS(getEventContent(), true);
2549             }
2550             return vCalendar.toString();
2551         }
2552 
2553         protected HttpException buildHttpException(Exception e) {
2554             String message = "Unable to get event " + getName() + " subject: " + subject + " at " + permanentUrl + ": " + e.getMessage();
2555             LOGGER.warn(message);
2556             return new HttpException(message);
2557         }
2558 
2559         /**
2560          * Retrieve item body from Exchange
2561          *
2562          * @return item content
2563          * @throws HttpException on error
2564          */
2565         public abstract byte[] getEventContent() throws IOException;
2566 
2567         protected static final String TEXT_CALENDAR = "text/calendar";
2568         protected static final String APPLICATION_ICS = "application/ics";
2569 
2570         protected boolean isCalendarContentType(String contentType) {
2571             return TEXT_CALENDAR.regionMatches(true, 0, contentType, 0, TEXT_CALENDAR.length()) ||
2572                     APPLICATION_ICS.regionMatches(true, 0, contentType, 0, APPLICATION_ICS.length());
2573         }
2574 
2575         protected MimePart getCalendarMimePart(MimeMultipart multiPart) throws IOException, MessagingException {
2576             MimePart bodyPart = null;
2577             for (int i = 0; i < multiPart.getCount(); i++) {
2578                 String contentType = multiPart.getBodyPart(i).getContentType();
2579                 if (isCalendarContentType(contentType)) {
2580                     bodyPart = (MimePart) multiPart.getBodyPart(i);
2581                     break;
2582                 } else if (contentType.startsWith("multipart")) {
2583                     Object content = multiPart.getBodyPart(i).getContent();
2584                     if (content instanceof MimeMultipart) {
2585                         bodyPart = getCalendarMimePart((MimeMultipart) content);
2586                     }
2587                 }
2588             }
2589 
2590             return bodyPart;
2591         }
2592 
2593         /**
2594          * Load ICS content from MIME message input stream
2595          *
2596          * @param mimeInputStream mime message input stream
2597          * @return mime message ics attachment body
2598          * @throws IOException        on error
2599          * @throws MessagingException on error
2600          */
2601         protected byte[] getICS(InputStream mimeInputStream) throws IOException, MessagingException {
2602             byte[] result;
2603             MimeMessage mimeMessage = new MimeMessage(null, mimeInputStream);
2604             String[] contentClassHeader = mimeMessage.getHeader("Content-class");
2605             // task item, return null
2606             if (contentClassHeader != null && contentClassHeader.length > 0 && "urn:content-classes:task".equals(contentClassHeader[0])) {
2607                 return null;
2608             }
2609             Object mimeBody = mimeMessage.getContent();
2610             MimePart bodyPart = null;
2611             if (mimeBody instanceof MimeMultipart) {
2612                 bodyPart = getCalendarMimePart((MimeMultipart) mimeBody);
2613             } else if (isCalendarContentType(mimeMessage.getContentType())) {
2614                 // no multipart, single body
2615                 bodyPart = mimeMessage;
2616             }
2617 
2618             if (bodyPart != null) {
2619                 ByteArrayOutputStream baos = new ByteArrayOutputStream();
2620                 bodyPart.getDataHandler().writeTo(baos);
2621                 baos.close();
2622                 result = baos.toByteArray();
2623             } else {
2624                 ByteArrayOutputStream baos = new ByteArrayOutputStream();
2625                 mimeMessage.writeTo(baos);
2626                 baos.close();
2627                 throw new DavMailException("EXCEPTION_INVALID_MESSAGE_CONTENT", new String(baos.toByteArray(), "UTF-8"));
2628             }
2629             return result;
2630         }
2631 
2632         protected void fixICS(byte[] icsContent, boolean fromServer) throws IOException {
2633             if (LOGGER.isDebugEnabled() && fromServer) {
2634                 dumpIndex++;
2635                 String icsBody = new String(icsContent, "UTF-8");
2636                 dumpICS(icsBody, fromServer, false);
2637                 LOGGER.debug("Vcalendar body received from server:\n" + icsBody);
2638             }
2639             vCalendar = new VCalendar(icsContent, getEmail(), getVTimezone());
2640             vCalendar.fixVCalendar(fromServer);
2641             if (LOGGER.isDebugEnabled() && !fromServer) {
2642                 String resultString = vCalendar.toString();
2643                 LOGGER.debug("Fixed Vcalendar body to server:\n" + resultString);
2644                 dumpICS(resultString, fromServer, true);
2645             }
2646         }
2647 
2648         protected void dumpICS(String icsBody, boolean fromServer, boolean after) {
2649             String logFileDirectory = Settings.getLogFileDirectory();
2650 
2651             // additional setting to activate ICS dump (not available in GUI)
2652             int dumpMax = Settings.getIntProperty("davmail.dumpICS");
2653             if (dumpMax > 0) {
2654                 if (dumpIndex > dumpMax) {
2655                     // Delete the oldest dump file
2656                     final int oldest = dumpIndex - dumpMax;
2657                     try {
2658                         File[] oldestFiles = (new File(logFileDirectory)).listFiles(new FilenameFilter() {
2659                             public boolean accept(File dir, String name) {
2660                                 if (name.endsWith(".ics")) {
2661                                     int dashIndex = name.indexOf('-');
2662                                     if (dashIndex > 0) {
2663                                         try {
2664                                             int fileIndex = Integer.parseInt(name.substring(0, dashIndex));
2665                                             return fileIndex < oldest;
2666                                         } catch (NumberFormatException nfe) {
2667                                             // ignore
2668                                         }
2669                                     }
2670                                 }
2671                                 return false;
2672                             }
2673                         });
2674                         if (oldestFiles != null) {
2675                             for (File file : oldestFiles) {
2676                                 if (!file.delete()) {
2677                                     LOGGER.warn("Unable to delete " + file.getAbsolutePath());
2678                                 }
2679                             }
2680                         }
2681                     } catch (Exception ex) {
2682                         LOGGER.warn("Error deleting ics dump: " + ex.getMessage());
2683                     }
2684                 }
2685 
2686                 StringBuilder filePath = new StringBuilder();
2687                 filePath.append(logFileDirectory).append('/')
2688                         .append(dumpIndex)
2689                         .append(after ? "-to" : "-from")
2690                         .append((after ^ fromServer) ? "-server" : "-client")
2691                         .append(".ics");
2692                 if ((icsBody != null) && (icsBody.length() > 0)) {
2693                     OutputStreamWriter writer = null;
2694                     try {
2695                         writer = new OutputStreamWriter(new FileOutputStream(filePath.toString()), "UTF-8");
2696                         writer.write(icsBody);
2697                     } catch (IOException e) {
2698                         LOGGER.error(e);
2699                     } finally {
2700                         if (writer != null) {
2701                             try {
2702                                 writer.close();
2703                             } catch (IOException e) {
2704                                 LOGGER.error(e);
2705                             }
2706                         }
2707                     }
2708 
2709 
2710                 }
2711             }
2712 
2713         }
2714 
2715         /**
2716          * Build Mime body for event or event message.
2717          *
2718          * @return mimeContent as byte array or null
2719          * @throws IOException on error
2720          */
2721         public byte[] createMimeContent() throws IOException {
2722             String boundary = UUID.randomUUID().toString();
2723             ByteArrayOutputStream baos = new ByteArrayOutputStream();
2724             MimeOutputStreamWriter writer = new MimeOutputStreamWriter(baos);
2725 
2726             writer.writeHeader("Content-Transfer-Encoding", "7bit");
2727             writer.writeHeader("Content-class", contentClass);
2728             // append date
2729             writer.writeHeader("Date", new Date());
2730 
2731             // Make sure invites have a proper subject line
2732             String vEventSubject = vCalendar.getFirstVeventPropertyValue("SUMMARY");
2733             if (vEventSubject == null) {
2734                 vEventSubject = BundleMessage.format("MEETING_REQUEST");
2735             }
2736 
2737             // Write a part of the message that contains the
2738             // ICS description so that invites contain the description text
2739             String description = vCalendar.getFirstVeventPropertyValue("DESCRIPTION");
2740 
2741             // handle notifications
2742             if ("urn:content-classes:calendarmessage".equals(contentClass)) {
2743                 // need to parse attendees and organizer to build recipients
2744                 VCalendar.Recipients recipients = vCalendar.getRecipients(true);
2745                 String to;
2746                 String cc;
2747                 String notificationSubject;
2748                 if (email.equalsIgnoreCase(recipients.organizer)) {
2749                     // current user is organizer => notify all
2750                     to = recipients.attendees;
2751                     cc = recipients.optionalAttendees;
2752                     notificationSubject = subject;
2753                 } else {
2754                     String status = vCalendar.getAttendeeStatus();
2755                     // notify only organizer
2756                     to = recipients.organizer;
2757                     cc = null;
2758                     notificationSubject = (status != null) ? (BundleMessage.format(status) + vEventSubject) : subject;
2759                     description = "";
2760                 }
2761 
2762                 // Allow end user notification edit
2763                 if (Settings.getBooleanProperty("davmail.caldavEditNotifications")) {
2764                     // create notification edit dialog
2765                     NotificationDialog notificationDialog = new NotificationDialog(to,
2766                             cc, notificationSubject, description);
2767                     if (!notificationDialog.getSendNotification()) {
2768                         LOGGER.debug("Notification canceled by user");
2769                         return null;
2770                     }
2771                     // get description from dialog
2772                     to = notificationDialog.getTo();
2773                     cc = notificationDialog.getCc();
2774                     notificationSubject = notificationDialog.getSubject();
2775                     description = notificationDialog.getBody();
2776                 }
2777 
2778                 // do not send notification if no recipients found
2779                 if ((to == null || to.length() == 0) && (cc == null || cc.length() == 0)) {
2780                     return null;
2781                 }
2782 
2783                 writer.writeHeader("To", to);
2784                 writer.writeHeader("Cc", cc);
2785                 writer.writeHeader("Subject", notificationSubject);
2786 
2787 
2788                 if (LOGGER.isDebugEnabled()) {
2789                     StringBuilder logBuffer = new StringBuilder("Sending notification ");
2790                     if (to != null) {
2791                         logBuffer.append("to: ").append(to);
2792                     }
2793                     if (cc != null) {
2794                         logBuffer.append("cc: ").append(cc);
2795                     }
2796                     LOGGER.debug(logBuffer.toString());
2797                 }
2798             } else {
2799                 // need to parse attendees and organizer to build recipients
2800                 VCalendar.Recipients recipients = vCalendar.getRecipients(false);
2801                 // storing appointment, full recipients header
2802                 if (recipients.attendees != null) {
2803                     writer.writeHeader("To", recipients.attendees);
2804                 } else {
2805                     // use current user as attendee
2806                     writer.writeHeader("To", email);
2807                 }
2808                 writer.writeHeader("Cc", recipients.optionalAttendees);
2809 
2810                 if (recipients.organizer != null) {
2811                     writer.writeHeader("From", recipients.organizer);
2812                 } else {
2813                     writer.writeHeader("From", email);
2814                 }
2815             }
2816             if (vCalendar.getMethod() == null) {
2817                 vCalendar.setPropertyValue("METHOD", "REQUEST");
2818             }
2819             writer.writeHeader("MIME-Version", "1.0");
2820             writer.writeHeader("Content-Type", "multipart/alternative;\r\n" +
2821                     "\tboundary=\"----=_NextPart_" + boundary + '\"');
2822             writer.writeLn();
2823             writer.writeLn("This is a multi-part message in MIME format.");
2824             writer.writeLn();
2825             writer.writeLn("------=_NextPart_" + boundary);
2826 
2827             if (description != null && description.length() > 0) {
2828                 writer.writeHeader("Content-Type", "text/plain;\r\n" +
2829                         "\tcharset=\"utf-8\"");
2830                 writer.writeHeader("content-transfer-encoding", "8bit");
2831                 writer.writeLn();
2832                 writer.flush();
2833                 baos.write(description.getBytes("UTF-8"));
2834                 writer.writeLn();
2835                 writer.writeLn("------=_NextPart_" + boundary);
2836             }
2837             writer.writeHeader("Content-class", contentClass);
2838             writer.writeHeader("Content-Type", "text/calendar;\r\n" +
2839                     "\tmethod=" + vCalendar.getMethod() + ";\r\n" +
2840                     "\tcharset=\"utf-8\""
2841             );
2842             writer.writeHeader("Content-Transfer-Encoding", "8bit");
2843             writer.writeLn();
2844             writer.flush();
2845             baos.write(vCalendar.toString().getBytes("UTF-8"));
2846             writer.writeLn();
2847             writer.writeLn("------=_NextPart_" + boundary + "--");
2848             writer.close();
2849             return baos.toByteArray();
2850         }
2851 
2852         /**
2853          * Create or update item
2854          *
2855          * @return action result
2856          * @throws IOException on error
2857          */
2858         public abstract ItemResult createOrUpdate() throws IOException;
2859 
2860     }
2861 
2862     protected abstract Set<String> getItemProperties();
2863 
2864     /**
2865      * Search contacts in provided folder.
2866      *
2867      * @param folderPath Exchange folder path
2868      * @return list of contacts
2869      * @throws IOException on error
2870      */
2871     public List<ExchangeSession.Contact> getAllContacts(String folderPath) throws IOException {
2872         return searchContacts(folderPath, ExchangeSession.CONTACT_ATTRIBUTES, isEqualTo("outlookmessageclass", "IPM.Contact"), 0);
2873     }
2874 
2875 
2876     /**
2877      * Search contacts in provided folder matching the search query.
2878      *
2879      * @param folderPath Exchange folder path
2880      * @param attributes requested attributes
2881      * @param condition  Exchange search query
2882      * @param maxCount   maximum item count
2883      * @return list of contacts
2884      * @throws IOException on error
2885      */
2886     public abstract List<Contact> searchContacts(String folderPath, Set<String> attributes, Condition condition, int maxCount) throws IOException;
2887 
2888     /**
2889      * Search calendar messages in provided folder.
2890      *
2891      * @param folderPath Exchange folder path
2892      * @return list of calendar messages as Event objects
2893      * @throws IOException on error
2894      */
2895     public abstract List<Event> getEventMessages(String folderPath) throws IOException;
2896 
2897     /**
2898      * Search calendar events in provided folder.
2899      *
2900      * @param folderPath Exchange folder path
2901      * @return list of calendar events
2902      * @throws IOException on error
2903      */
2904     public List<Event> getAllEvents(String folderPath) throws IOException {
2905         List<Event> results = searchEvents(folderPath, getCalendarItemCondition(getPastDelayCondition("dtstart")));
2906 
2907         if (!Settings.getBooleanProperty("davmail.caldavDisableTasks", false) && isMainCalendar(folderPath)) {
2908             // retrieve tasks from main tasks folder
2909             results.addAll(searchTasksOnly(TASKS));
2910         }
2911 
2912         return results;
2913     }
2914 
2915     protected abstract Condition getCalendarItemCondition(Condition dateCondition);
2916 
2917     protected Condition getPastDelayCondition(String attribute) {
2918         int caldavPastDelay = Settings.getIntProperty("davmail.caldavPastDelay");
2919         Condition dateCondition = null;
2920         if (caldavPastDelay != 0) {
2921             Calendar cal = Calendar.getInstance();
2922             cal.add(Calendar.DAY_OF_MONTH, -caldavPastDelay);
2923             dateCondition = gt(attribute, formatSearchDate(cal.getTime()));
2924         }
2925         return dateCondition;
2926     }
2927 
2928     protected Condition getRangeCondition(String timeRangeStart, String timeRangeEnd) throws IOException {
2929         try {
2930             SimpleDateFormat parser = getZuluDateFormat();
2931             ExchangeSession.MultiCondition andCondition = and();
2932             if (timeRangeStart != null) {
2933                 andCondition.add(gt("dtend", formatSearchDate(parser.parse(timeRangeStart))));
2934             }
2935             if (timeRangeEnd != null) {
2936                 andCondition.add(lt("dtstart", formatSearchDate(parser.parse(timeRangeEnd))));
2937             }
2938             return andCondition;
2939         } catch (ParseException e) {
2940             throw new IOException(e + " " + e.getMessage());
2941         }
2942     }
2943 
2944     /**
2945      * Search events between start and end.
2946      *
2947      * @param folderPath     Exchange folder path
2948      * @param timeRangeStart date range start in zulu format
2949      * @param timeRangeEnd   date range start in zulu format
2950      * @return list of calendar events
2951      * @throws IOException on error
2952      */
2953     public List<Event> searchEvents(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException {
2954         Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd);
2955         Condition condition = getCalendarItemCondition(dateCondition);
2956 
2957         return searchEvents(folderPath, condition);
2958     }
2959 
2960     /**
2961      * Search events between start and end, exclude tasks.
2962      *
2963      * @param folderPath     Exchange folder path
2964      * @param timeRangeStart date range start in zulu format
2965      * @param timeRangeEnd   date range start in zulu format
2966      * @return list of calendar events
2967      * @throws IOException on error
2968      */
2969     public List<Event> searchEventsOnly(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException {
2970         Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd);
2971         return searchEvents(folderPath, getCalendarItemCondition(dateCondition));
2972     }
2973 
2974     /**
2975      * Search tasks only (VTODO).
2976      *
2977      * @param folderPath Exchange folder path
2978      * @return list of tasks
2979      * @throws IOException on error
2980      */
2981     public List<Event> searchTasksOnly(String folderPath) throws IOException {
2982         return searchEvents(folderPath, and(isEqualTo("outlookmessageclass", "IPM.Task"),
2983                 or(isNull("datecompleted"), getPastDelayCondition("datecompleted"))));
2984     }
2985 
2986     /**
2987      * Search calendar events in provided folder.
2988      *
2989      * @param folderPath Exchange folder path
2990      * @param filter     search filter
2991      * @return list of calendar events
2992      * @throws IOException on error
2993      */
2994     public List<Event> searchEvents(String folderPath, Condition filter) throws IOException {
2995 
2996         Condition privateCondition = null;
2997         if (isSharedFolder(folderPath) && Settings.getBooleanProperty("davmail.excludePrivateEvents", true)) {
2998             LOGGER.debug("Shared or public calendar: exclude private events");
2999             privateCondition = isEqualTo("sensitivity", 0);
3000         }
3001 
3002         return searchEvents(folderPath, getItemProperties(),
3003                 and(filter, privateCondition));
3004     }
3005 
3006     /**
3007      * Search calendar events or messages in provided folder matching the search query.
3008      *
3009      * @param folderPath Exchange folder path
3010      * @param attributes requested attributes
3011      * @param condition  Exchange search query
3012      * @return list of calendar messages as Event objects
3013      * @throws IOException on error
3014      */
3015     public abstract List<Event> searchEvents(String folderPath, Set<String> attributes, Condition condition) throws IOException;
3016 
3017     /**
3018      * convert vcf extension to EML.
3019      *
3020      * @param itemName item name
3021      * @return EML item name
3022      */
3023     protected String convertItemNameToEML(String itemName) {
3024         if (itemName.endsWith(".vcf")) {
3025             return itemName.substring(0, itemName.length() - 3) + "EML";
3026         } else {
3027             return itemName;
3028         }
3029     }
3030 
3031     /**
3032      * Get item named eventName in folder
3033      *
3034      * @param folderPath Exchange folder path
3035      * @param itemName   event name
3036      * @return event object
3037      * @throws IOException on error
3038      */
3039     public abstract Item getItem(String folderPath, String itemName) throws IOException;
3040 
3041     /**
3042      * Contact picture
3043      */
3044     public static class ContactPhoto {
3045         /**
3046          * Contact picture content type (always image/jpeg on read)
3047          */
3048         public String contentType;
3049         /**
3050          * Base64 encoded picture content
3051          */
3052         public String content;
3053     }
3054 
3055     /**
3056      * Retrieve contact photo attached to contact
3057      *
3058      * @param contact address book contact
3059      * @return contact photo
3060      * @throws IOException on error
3061      */
3062     public abstract ContactPhoto getContactPhoto(Contact contact) throws IOException;
3063 
3064 
3065     /**
3066      * Delete event named itemName in folder
3067      *
3068      * @param folderPath Exchange folder path
3069      * @param itemName   item name
3070      * @throws IOException on error
3071      */
3072     public abstract void deleteItem(String folderPath, String itemName) throws IOException;
3073 
3074     /**
3075      * Mark event processed named eventName in folder
3076      *
3077      * @param folderPath Exchange folder path
3078      * @param itemName   item name
3079      * @throws IOException on error
3080      */
3081     public abstract void processItem(String folderPath, String itemName) throws IOException;
3082 
3083 
3084     private static int dumpIndex;
3085 
3086     /**
3087      * Replace iCal4 (Snow Leopard) principal paths with mailto expression
3088      *
3089      * @param value attendee value or ics line
3090      * @return fixed value
3091      */
3092     protected String replaceIcal4Principal(String value) {
3093         if (value != null && value.contains("/principals/__uuids__/")) {
3094             return value.replaceAll("/principals/__uuids__/([^/]*)__AT__([^/]*)/", "mailto:$1@$2");
3095         } else {
3096             return value;
3097         }
3098     }
3099 
3100     /**
3101      * Event result object to hold HTTP status and event etag from an event creation/update.
3102      */
3103     public static class ItemResult {
3104         /**
3105          * HTTP status
3106          */
3107         public int status;
3108         /**
3109          * Event etag from response HTTP header
3110          */
3111         public String etag;
3112     }
3113 
3114     /**
3115      * Build and send the MIME message for the provided ICS event.
3116      *
3117      * @param icsBody event in iCalendar format
3118      * @return HTTP status
3119      * @throws IOException on error
3120      */
3121     public abstract int sendEvent(String icsBody) throws IOException;
3122 
3123     /**
3124      * Create or update item (event or contact) on the Exchange server
3125      *
3126      * @param folderPath Exchange folder path
3127      * @param itemName   event name
3128      * @param itemBody   event body in iCalendar format
3129      * @param etag       previous event etag to detect concurrent updates
3130      * @param noneMatch  if-none-match header value
3131      * @return HTTP response event result (status and etag)
3132      * @throws IOException on error
3133      */
3134     public ItemResult createOrUpdateItem(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException {
3135         if (itemBody.startsWith("BEGIN:VCALENDAR")) {
3136             return internalCreateOrUpdateEvent(folderPath, itemName, "urn:content-classes:appointment", itemBody, etag, noneMatch);
3137         } else if (itemBody.startsWith("BEGIN:VCARD")) {
3138             return createOrUpdateContact(folderPath, itemName, itemBody, etag, noneMatch);
3139         } else {
3140             throw new IOException(BundleMessage.format("EXCEPTION_INVALID_MESSAGE_CONTENT", itemBody));
3141         }
3142     }
3143 
3144     static final String[] VCARD_N_PROPERTIES = {"sn", "givenName", "middlename", "personaltitle", "namesuffix"};
3145     static final String[] VCARD_ADR_HOME_PROPERTIES = {"homepostofficebox", null, "homeStreet", "homeCity", "homeState", "homePostalCode", "homeCountry"};
3146     static final String[] VCARD_ADR_WORK_PROPERTIES = {"postofficebox", "roomnumber", "street", "l", "st", "postalcode", "co"};
3147     static final String[] VCARD_ADR_OTHER_PROPERTIES = {"otherpostofficebox", null, "otherstreet", "othercity", "otherstate", "otherpostalcode", "othercountry"};
3148     static final String[] VCARD_ORG_PROPERTIES = {"o", "department"};
3149 
3150     protected void convertContactProperties(Map<String, String> properties, String[] contactProperties, List<String> values) {
3151         for (int i = 0; i < values.size() && i < contactProperties.length; i++) {
3152             if (contactProperties[i] != null) {
3153                 properties.put(contactProperties[i], values.get(i));
3154             }
3155         }
3156     }
3157 
3158     protected ItemResult createOrUpdateContact(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException {
3159         // parse VCARD body to build contact property map
3160         Map<String, String> properties = new HashMap<String, String>();
3161         properties.put("outlookmessageclass", "IPM.Contact");
3162 
3163         VObject vcard = new VObject(new ICSBufferedReader(new StringReader(itemBody)));
3164         for (VProperty property : vcard.getProperties()) {
3165             if ("FN".equals(property.getKey())) {
3166                 properties.put("cn", property.getValue());
3167                 properties.put("subject", property.getValue());
3168                 properties.put("fileas", property.getValue());
3169 
3170             } else if ("N".equals(property.getKey())) {
3171                 convertContactProperties(properties, VCARD_N_PROPERTIES, property.getValues());
3172             } else if ("NICKNAME".equals(property.getKey())) {
3173                 properties.put("nickname", property.getValue());
3174             } else if ("TEL".equals(property.getKey())) {
3175                 if (property.hasParam("TYPE", "cell") || property.hasParam("X-GROUP", "cell")) {
3176                     properties.put("mobile", property.getValue());
3177                 } else if (property.hasParam("TYPE", "work") || property.hasParam("X-GROUP", "work")) {
3178                     properties.put("telephoneNumber", property.getValue());
3179                 } else if (property.hasParam("TYPE", "home") || property.hasParam("X-GROUP", "home")) {
3180                     properties.put("homePhone", property.getValue());
3181                 } else if (property.hasParam("TYPE", "fax")) {
3182                     if (property.hasParam("TYPE", "home")) {
3183                         properties.put("homefax", property.getValue());
3184                     } else {
3185                         properties.put("facsimiletelephonenumber", property.getValue());
3186                     }
3187                 } else if (property.hasParam("TYPE", "pager")) {
3188                     properties.put("pager", property.getValue());
3189                 } else if (property.hasParam("TYPE", "car")) {
3190                     properties.put("othermobile", property.getValue());
3191                 } else {
3192                     properties.put("otherTelephone", property.getValue());
3193                 }
3194             } else if ("ADR".equals(property.getKey())) {
3195                 // address
3196                 if (property.hasParam("TYPE", "home")) {
3197                     convertContactProperties(properties, VCARD_ADR_HOME_PROPERTIES, property.getValues());
3198                 } else if (property.hasParam("TYPE", "work")) {
3199                     convertContactProperties(properties, VCARD_ADR_WORK_PROPERTIES, property.getValues());
3200                     // any other type goes to other address
3201                 } else {
3202                     convertContactProperties(properties, VCARD_ADR_OTHER_PROPERTIES, property.getValues());
3203                 }
3204             } else if ("EMAIL".equals(property.getKey())) {
3205                 if (property.hasParam("TYPE", "home")) {
3206                     properties.put("email2", property.getValue());
3207                     properties.put("smtpemail2", property.getValue());
3208                 } else if (property.hasParam("TYPE", "other")) {
3209                     properties.put("email3", property.getValue());
3210                     properties.put("smtpemail3", property.getValue());
3211                 } else {
3212                     properties.put("email1", property.getValue());
3213                     properties.put("smtpemail1", property.getValue());
3214                 }
3215             } else if ("ORG".equals(property.getKey())) {
3216                 convertContactProperties(properties, VCARD_ORG_PROPERTIES, property.getValues());
3217             } else if ("URL".equals(property.getKey())) {
3218                 if (property.hasParam("TYPE", "work")) {
3219                     properties.put("businesshomepage", property.getValue());
3220                 } else if (property.hasParam("TYPE", "home")) {
3221                     properties.put("personalHomePage", property.getValue());
3222                 } else {
3223                     // default: set personal home page
3224                     properties.put("personalHomePage", property.getValue());
3225                 }
3226             } else if ("TITLE".equals(property.getKey())) {
3227                 properties.put("title", property.getValue());
3228             } else if ("NOTE".equals(property.getKey())) {
3229                 properties.put("description", property.getValue());
3230             } else if ("CUSTOM1".equals(property.getKey())) {
3231                 properties.put("extensionattribute1", property.getValue());
3232             } else if ("CUSTOM2".equals(property.getKey())) {
3233                 properties.put("extensionattribute2", property.getValue());
3234             } else if ("CUSTOM3".equals(property.getKey())) {
3235                 properties.put("extensionattribute3", property.getValue());
3236             } else if ("CUSTOM4".equals(property.getKey())) {
3237                 properties.put("extensionattribute4", property.getValue());
3238             } else if ("ROLE".equals(property.getKey())) {
3239                 properties.put("profession", property.getValue());
3240             } else if ("X-AIM".equals(property.getKey())) {
3241                 properties.put("im", property.getValue());
3242             } else if ("BDAY".equals(property.getKey())) {
3243                 properties.put("bday", convertBDayToZulu(property.getValue()));
3244             } else if ("ANNIVERSARY".equals(property.getKey()) || "X-ANNIVERSARY".equals(property.getKey())) {
3245                 properties.put("anniversary", convertBDayToZulu(property.getValue()));
3246             } else if ("CATEGORIES".equals(property.getKey())) {
3247                 properties.put("keywords", property.getValue());
3248             } else if ("CLASS".equals(property.getKey())) {
3249                 if ("PUBLIC".equals(property.getValue())) {
3250                     properties.put("sensitivity", "0");
3251                     properties.put("private", "false");
3252                 } else {
3253                     properties.put("sensitivity", "2");
3254                     properties.put("private", "true");
3255                 }
3256             } else if ("SEX".equals(property.getKey())) {
3257                 String propertyValue = property.getValue();
3258                 if ("1".equals(propertyValue)) {
3259                     properties.put("gender", "2");
3260                 } else if ("2".equals(propertyValue)) {
3261                     properties.put("gender", "1");
3262                 }
3263             } else if ("FBURL".equals(property.getKey())) {
3264                 properties.put("fburl", property.getValue());
3265             } else if ("X-ASSISTANT".equals(property.getKey())) {
3266                 properties.put("secretarycn", property.getValue());
3267             } else if ("X-MANAGER".equals(property.getKey())) {
3268                 properties.put("manager", property.getValue());
3269             } else if ("X-SPOUSE".equals(property.getKey())) {
3270                 properties.put("spousecn", property.getValue());
3271             } else if ("PHOTO".equals(property.getKey())) {
3272                 properties.put("photo", property.getValue());
3273                 properties.put("haspicture", "true");
3274             }
3275         }
3276         LOGGER.debug("Create or update contact " + itemName + ": " + properties);
3277         // reset missing properties to null
3278         for (String key : CONTACT_ATTRIBUTES) {
3279             if (!"imapUid".equals(key) && !"etag".equals(key) && !"urlcompname".equals(key)
3280                     && !"lastmodified".equals(key) && !"sensitivity".equals(key) &&
3281                     !properties.containsKey(key)) {
3282                 properties.put(key, null);
3283             }
3284         }
3285         return internalCreateOrUpdateContact(folderPath, itemName, properties, etag, noneMatch);
3286     }
3287 
3288     protected String convertZuluDateToBday(String value) {
3289         String result = null;
3290         if (value != null && value.length() > 0) {
3291             try {
3292                 SimpleDateFormat parser = ExchangeSession.getZuluDateFormat();
3293                 Calendar cal = Calendar.getInstance();
3294                 cal.setTime(parser.parse(value));
3295                 cal.add(Calendar.HOUR_OF_DAY, 12);
3296                 result = ExchangeSession.getVcardBdayFormat().format(cal.getTime());
3297             } catch (ParseException e) {
3298                 LOGGER.warn("Invalid date: " + value);
3299             }
3300         }
3301         return result;
3302     }
3303 
3304     protected String convertBDayToZulu(String value) {
3305         String result = null;
3306         if (value != null && value.length() > 0) {
3307             try {
3308                 SimpleDateFormat parser;
3309                 if (value.length() == 10) {
3310                     parser = ExchangeSession.getVcardBdayFormat();
3311                 } else if (value.length() == 15) {
3312                     parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ENGLISH);
3313                     parser.setTimeZone(GMT_TIMEZONE);
3314                 } else {
3315                     parser = ExchangeSession.getExchangeZuluDateFormat();
3316                 }
3317                 result = ExchangeSession.getExchangeZuluDateFormatMillisecond().format(parser.parse(value));
3318             } catch (ParseException e) {
3319                 LOGGER.warn("Invalid date: " + value);
3320             }
3321         }
3322 
3323         return result;
3324     }
3325 
3326 
3327     protected abstract ItemResult internalCreateOrUpdateContact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) throws IOException;
3328 
3329     protected abstract ItemResult internalCreateOrUpdateEvent(String folderPath, String itemName, String contentClass, String icsBody, String etag, String noneMatch) throws IOException;
3330 
3331     /**
3332      * Get current Exchange alias name from login name
3333      *
3334      * @return user name
3335      */
3336     public String getAliasFromLogin() {
3337         // login is email, not alias
3338         if (this.userName.indexOf('@') >= 0) {
3339             return null;
3340         }
3341         String result = this.userName;
3342         // remove domain name
3343         int index = Math.max(result.indexOf('\\'), result.indexOf('/'));
3344         if (index >= 0) {
3345             result = result.substring(index + 1);
3346         }
3347         return result;
3348     }
3349 
3350     protected String getEmailSuffixFromHostname() {
3351         String domain = httpClient.getHostConfiguration().getHost();
3352         int start = domain.lastIndexOf('.', domain.lastIndexOf('.') - 1);
3353         if (start >= 0) {
3354             return '@' + domain.substring(start + 1);
3355         } else {
3356             return '@' + domain;
3357         }
3358     }
3359 
3360     /**
3361      * Test if folderPath is inside user mailbox.
3362      *
3363      * @param folderPath absolute folder path
3364      * @return true if folderPath is a public or shared folder
3365      */
3366     public abstract boolean isSharedFolder(String folderPath);
3367 
3368     /**
3369      * Test if folderPath is main calendar.
3370      *
3371      * @param folderPath absolute folder path
3372      * @return true if folderPath is a public or shared folder
3373      */
3374     public abstract boolean isMainCalendar(String folderPath) throws IOException;
3375 
3376     static final String MAILBOX_BASE = "/cn=";
3377 
3378     protected void getEmailAndAliasFromOptions() {
3379         synchronized (httpClient.getState()) {
3380             Cookie[] currentCookies = httpClient.getState().getCookies();
3381             // get user mail URL from html body
3382             BufferedReader optionsPageReader = null;
3383             GetMethod optionsMethod = new GetMethod("/owa/?ae=Options&t=About");
3384             try {
3385                 DavGatewayHttpClientFacade.executeGetMethod(httpClient, optionsMethod, false);
3386                 optionsPageReader = new BufferedReader(new InputStreamReader(optionsMethod.getResponseBodyAsStream(), "UTF-8"));
3387                 String line;
3388 
3389                 // find email and alias
3390                 //noinspection StatementWithEmptyBody
3391                 while ((line = optionsPageReader.readLine()) != null
3392                         && (line.indexOf('[') == -1
3393                         || line.indexOf('@') == -1
3394                         || line.indexOf(']') == -1
3395                         || !line.toLowerCase().contains(MAILBOX_BASE))) {
3396                 }
3397                 if (line != null) {
3398                     int start = line.toLowerCase().lastIndexOf(MAILBOX_BASE) + MAILBOX_BASE.length();
3399                     int end = line.indexOf('<', start);
3400                     alias = line.substring(start, end);
3401                     end = line.lastIndexOf(']');
3402                     start = line.lastIndexOf('[', end) + 1;
3403                     email = line.substring(start, end);
3404                 }
3405             } catch (IOException e) {
3406                 // restore cookies on error
3407                 httpClient.getState().addCookies(currentCookies);
3408                 LOGGER.error("Error parsing options page at " + optionsMethod.getPath());
3409             } finally {
3410                 if (optionsPageReader != null) {
3411                     try {
3412                         optionsPageReader.close();
3413                     } catch (IOException e) {
3414                         LOGGER.error("Error parsing options page at " + optionsMethod.getPath());
3415                     }
3416                 }
3417                 optionsMethod.releaseConnection();
3418             }
3419         }
3420     }
3421 
3422     /**
3423      * Get current user email
3424      *
3425      * @return user email
3426      */
3427     public String getEmail() {
3428         return email;
3429     }
3430 
3431     /**
3432      * Get current user alias
3433      *
3434      * @return user email
3435      */
3436     public String getAlias() {
3437         return alias;
3438     }
3439 
3440     /**
3441      * Search global address list
3442      *
3443      * @param condition           search filter
3444      * @param returningAttributes returning attributes
3445      * @param sizeLimit           size limit
3446      * @return matching contacts from gal
3447      * @throws IOException on error
3448      */
3449     public abstract Map<String, Contact> galFind(Condition condition, Set<String> returningAttributes, int sizeLimit) throws IOException;
3450 
3451     /**
3452      * Full Contact attribute list
3453      */
3454     public static final Set<String> CONTACT_ATTRIBUTES = new HashSet<String>();
3455 
3456     static {
3457         CONTACT_ATTRIBUTES.add("imapUid");
3458         CONTACT_ATTRIBUTES.add("etag");
3459         CONTACT_ATTRIBUTES.add("urlcompname");
3460 
3461         CONTACT_ATTRIBUTES.add("extensionattribute1");
3462         CONTACT_ATTRIBUTES.add("extensionattribute2");
3463         CONTACT_ATTRIBUTES.add("extensionattribute3");
3464         CONTACT_ATTRIBUTES.add("extensionattribute4");
3465         CONTACT_ATTRIBUTES.add("bday");
3466         CONTACT_ATTRIBUTES.add("anniversary");
3467         CONTACT_ATTRIBUTES.add("businesshomepage");
3468         CONTACT_ATTRIBUTES.add("personalHomePage");
3469         CONTACT_ATTRIBUTES.add("cn");
3470         CONTACT_ATTRIBUTES.add("co");
3471         CONTACT_ATTRIBUTES.add("department");
3472         CONTACT_ATTRIBUTES.add("smtpemail1");
3473         CONTACT_ATTRIBUTES.add("smtpemail2");
3474         CONTACT_ATTRIBUTES.add("smtpemail3");
3475         CONTACT_ATTRIBUTES.add("facsimiletelephonenumber");
3476         CONTACT_ATTRIBUTES.add("givenName");
3477         CONTACT_ATTRIBUTES.add("homeCity");
3478         CONTACT_ATTRIBUTES.add("homeCountry");
3479         CONTACT_ATTRIBUTES.add("homePhone");
3480         CONTACT_ATTRIBUTES.add("homePostalCode");
3481         CONTACT_ATTRIBUTES.add("homeState");
3482         CONTACT_ATTRIBUTES.add("homeStreet");
3483         CONTACT_ATTRIBUTES.add("homepostofficebox");
3484         CONTACT_ATTRIBUTES.add("l");
3485         CONTACT_ATTRIBUTES.add("manager");
3486         CONTACT_ATTRIBUTES.add("mobile");
3487         CONTACT_ATTRIBUTES.add("namesuffix");
3488         CONTACT_ATTRIBUTES.add("nickname");
3489         CONTACT_ATTRIBUTES.add("o");
3490         CONTACT_ATTRIBUTES.add("pager");
3491         CONTACT_ATTRIBUTES.add("personaltitle");
3492         CONTACT_ATTRIBUTES.add("postalcode");
3493         CONTACT_ATTRIBUTES.add("postofficebox");
3494         CONTACT_ATTRIBUTES.add("profession");
3495         CONTACT_ATTRIBUTES.add("roomnumber");
3496         CONTACT_ATTRIBUTES.add("secretarycn");
3497         CONTACT_ATTRIBUTES.add("sn");
3498         CONTACT_ATTRIBUTES.add("spousecn");
3499         CONTACT_ATTRIBUTES.add("st");
3500         CONTACT_ATTRIBUTES.add("street");
3501         CONTACT_ATTRIBUTES.add("telephoneNumber");
3502         CONTACT_ATTRIBUTES.add("title");
3503         CONTACT_ATTRIBUTES.add("description");
3504         CONTACT_ATTRIBUTES.add("im");
3505         CONTACT_ATTRIBUTES.add("middlename");
3506         CONTACT_ATTRIBUTES.add("lastmodified");
3507         CONTACT_ATTRIBUTES.add("otherstreet");
3508         CONTACT_ATTRIBUTES.add("otherstate");
3509         CONTACT_ATTRIBUTES.add("otherpostofficebox");
3510         CONTACT_ATTRIBUTES.add("otherpostalcode");
3511         CONTACT_ATTRIBUTES.add("othercountry");
3512         CONTACT_ATTRIBUTES.add("othercity");
3513         CONTACT_ATTRIBUTES.add("haspicture");
3514         CONTACT_ATTRIBUTES.add("keywords");
3515         CONTACT_ATTRIBUTES.add("othermobile");
3516         CONTACT_ATTRIBUTES.add("otherTelephone");
3517         CONTACT_ATTRIBUTES.add("gender");
3518         CONTACT_ATTRIBUTES.add("private");
3519         CONTACT_ATTRIBUTES.add("sensitivity");
3520         CONTACT_ATTRIBUTES.add("fburl");
3521     }
3522 
3523     /**
3524      * Get freebusy data string from Exchange.
3525      *
3526      * @param attendee attendee email address
3527      * @param start    start date in Exchange zulu format
3528      * @param end      end date in Exchange zulu format
3529      * @param interval freebusy interval in minutes
3530      * @return freebusy data or null
3531      * @throws IOException on error
3532      */
3533     protected abstract String getFreeBusyData(String attendee, String start, String end, int interval) throws IOException;
3534 
3535     /**
3536      * Get freebusy info for attendee between start and end date.
3537      *
3538      * @param attendee       attendee email
3539      * @param startDateValue start date
3540      * @param endDateValue   end date
3541      * @return FreeBusy info
3542      * @throws IOException on error
3543      */
3544     public FreeBusy getFreebusy(String attendee, String startDateValue, String endDateValue) throws IOException {
3545         // replace ical encoded attendee name
3546         attendee = replaceIcal4Principal(attendee);
3547 
3548         // then check that email address is valid to avoid InvalidSmtpAddress error
3549         if (attendee == null || attendee.indexOf('@') < 0 || attendee.charAt(attendee.length() - 1) == '@') {
3550             return null;
3551         }
3552 
3553         if (attendee.startsWith("mailto:") || attendee.startsWith("MAILTO:")) {
3554             attendee = attendee.substring("mailto:".length());
3555         }
3556 
3557         SimpleDateFormat exchangeZuluDateFormat = getExchangeZuluDateFormat();
3558         SimpleDateFormat icalDateFormat = getZuluDateFormat();
3559 
3560         Date startDate;
3561         Date endDate;
3562         try {
3563             if (startDateValue.length() == 8) {
3564                 startDate = parseDate(startDateValue);
3565             } else {
3566                 startDate = icalDateFormat.parse(startDateValue);
3567             }
3568             if (endDateValue.length() == 8) {
3569                 endDate = parseDate(endDateValue);
3570             } else {
3571                 endDate = icalDateFormat.parse(endDateValue);
3572             }
3573         } catch (ParseException e) {
3574             throw new DavMailException("EXCEPTION_INVALID_DATES", e.getMessage());
3575         }
3576 
3577         FreeBusy freeBusy = null;
3578         String fbdata = getFreeBusyData(attendee, exchangeZuluDateFormat.format(startDate), exchangeZuluDateFormat.format(endDate), FREE_BUSY_INTERVAL);
3579         if (fbdata != null) {
3580             freeBusy = new FreeBusy(icalDateFormat, startDate, fbdata);
3581         }
3582 
3583         if (freeBusy != null && freeBusy.knownAttendee) {
3584             return freeBusy;
3585         } else {
3586             return null;
3587         }
3588     }
3589 
3590     /**
3591      * Exchange to iCalendar Free/Busy parser.
3592      * Free time returns 0, Tentative returns 1, Busy returns 2, and Out of Office (OOF) returns 3
3593      */
3594     public static final class FreeBusy {
3595         final SimpleDateFormat icalParser;
3596         boolean knownAttendee = true;
3597         static final HashMap<Character, String> FBTYPES = new HashMap<Character, String>();
3598 
3599         static {
3600             FBTYPES.put('1', "BUSY-TENTATIVE");
3601             FBTYPES.put('2', "BUSY");
3602             FBTYPES.put('3', "BUSY-UNAVAILABLE");
3603         }
3604 
3605         final HashMap<String, StringBuilder> busyMap = new HashMap<String, StringBuilder>();
3606 
3607         StringBuilder getBusyBuffer(char type) {
3608             String fbType = FBTYPES.get(Character.valueOf(type));
3609             StringBuilder buffer = busyMap.get(fbType);
3610             if (buffer == null) {
3611                 buffer = new StringBuilder();
3612                 busyMap.put(fbType, buffer);
3613             }
3614             return buffer;
3615         }
3616 
3617         void startBusy(char type, Calendar currentCal) {
3618             if (type == '4') {
3619                 knownAttendee = false;
3620             } else if (type != '0') {
3621                 StringBuilder busyBuffer = getBusyBuffer(type);
3622                 if (busyBuffer.length() > 0) {
3623                     busyBuffer.append(',');
3624                 }
3625                 busyBuffer.append(icalParser.format(currentCal.getTime()));
3626             }
3627         }
3628 
3629         void endBusy(char type, Calendar currentCal) {
3630             if (type != '0' && type != '4') {
3631                 getBusyBuffer(type).append('/').append(icalParser.format(currentCal.getTime()));
3632             }
3633         }
3634 
3635         FreeBusy(SimpleDateFormat icalParser, Date startDate, String fbdata) {
3636             this.icalParser = icalParser;
3637             if (fbdata.length() > 0) {
3638                 Calendar currentCal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
3639                 currentCal.setTime(startDate);
3640 
3641                 startBusy(fbdata.charAt(0), currentCal);
3642                 for (int i = 1; i < fbdata.length() && knownAttendee; i++) {
3643                     currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL);
3644                     char previousState = fbdata.charAt(i - 1);
3645                     char currentState = fbdata.charAt(i);
3646                     if (previousState != currentState) {
3647                         endBusy(previousState, currentCal);
3648                         startBusy(currentState, currentCal);
3649                     }
3650                 }
3651                 currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL);
3652                 endBusy(fbdata.charAt(fbdata.length() - 1), currentCal);
3653             }
3654         }
3655 
3656         /**
3657          * Append freebusy information to buffer.
3658          *
3659          * @param buffer String buffer
3660          */
3661         public void appendTo(StringBuilder buffer) {
3662             for (Map.Entry<String, StringBuilder> entry : busyMap.entrySet()) {
3663                 buffer.append("FREEBUSY;FBTYPE=").append(entry.getKey())
3664                         .append(':').append(entry.getValue()).append((char) 13).append((char) 10);
3665             }
3666         }
3667     }
3668 
3669     protected VObject vTimezone;
3670 
3671     /**
3672      * Load and return current user OWA timezone.
3673      *
3674      * @return current timezone
3675      */
3676     public VObject getVTimezone() {
3677         if (vTimezone == null) {
3678             // need to load Timezone info from OWA
3679             loadVtimezone();
3680         }
3681         return vTimezone;
3682     }
3683 
3684     protected abstract void loadVtimezone();
3685 
3686     /**
3687      * Return internal HttpClient instance
3688      *
3689      * @return http client
3690      */
3691     public HttpClient getHttpClient() {
3692         return httpClient;
3693     }
3694 }