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