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 the 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 the specified folder.
230      * Will overwrite an existing message with the 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     /**
774      * Moves Resent headers to standard headers
775      */
776     protected void convertResentHeader(MimeMessage mimeMessage, String headerName) throws MessagingException {
777         String[] resentHeader = mimeMessage.getHeader("Resent-" + headerName);
778         if (resentHeader != null) {
779             mimeMessage.removeHeader("Resent-" + headerName);
780             mimeMessage.removeHeader(headerName);
781             for (String value : resentHeader) {
782                 mimeMessage.addHeader(headerName, value);
783             }
784         }
785     }
786 
787     protected String lastSentMessageId;
788     protected List<String> lastRcptToRecipients;
789 
790     /**
791      * Send the provided message to recipients.
792      * Detect visible recipients in the message body to determine bcc recipients
793      *
794      * @param rcptToRecipients recipient list
795      * @param mimeMessage      mime message
796      * @throws IOException        on error
797      * @throws MessagingException on error
798      */
799     public void sendMessage(List<String> rcptToRecipients, MimeMessage mimeMessage) throws IOException, MessagingException {
800         // detect duplicate send command
801         String messageId = mimeMessage.getMessageID();
802         if (lastSentMessageId != null && lastSentMessageId.equals(messageId)) {
803             // Resends duplicate message if allowed or recipients differ
804             if (Settings.getBooleanProperty("davmail.smtpAllowDuplicateSend", false)) {
805                 LOGGER.debug("Detected duplicate message id " + messageId + " but smtpAllowDuplicateSend is enabled, resending message");
806             } else if (lastRcptToRecipients != null && !lastRcptToRecipients.equals(rcptToRecipients)) {
807                 LOGGER.debug("Detected duplicate message id " + messageId + " but recipients differ, resending message");
808             } else {
809                 LOGGER.debug("Dropping message id " + messageId + ": already sent");
810                 return;
811             }
812         }
813         LOGGER.debug("Sending message id " + messageId);
814 
815         lastSentMessageId = messageId;
816         lastRcptToRecipients = rcptToRecipients;
817 
818         convertResentHeader(mimeMessage, "From");
819         convertResentHeader(mimeMessage, "To");
820         convertResentHeader(mimeMessage, "Cc");
821         convertResentHeader(mimeMessage, "Bcc");
822         convertResentHeader(mimeMessage, "Message-Id");
823 
824         // do not allow send as another user on Exchange 2003
825         if ("Exchange2003".equals(serverVersion) || Settings.getBooleanProperty("davmail.smtpStripFrom", false)) {
826             mimeMessage.removeHeader("From");
827         }
828 
829         // remove visible recipients from list
830         Set<String> visibleRecipients = new HashSet<>();
831         List<InternetAddress> recipients = getAllRecipients(mimeMessage);
832         for (InternetAddress address : recipients) {
833             visibleRecipients.add((address.getAddress().toLowerCase()));
834         }
835         for (String recipient : rcptToRecipients) {
836             if (!visibleRecipients.contains(recipient.toLowerCase())) {
837                 mimeMessage.addRecipient(javax.mail.Message.RecipientType.BCC, new InternetAddress(recipient));
838             }
839         }
840         sendMessage(mimeMessage);
841 
842     }
843 
844     static final String[] RECIPIENT_HEADERS = {"to", "cc", "bcc"};
845 
846     protected List<InternetAddress> getAllRecipients(MimeMessage mimeMessage) throws MessagingException {
847         List<InternetAddress> recipientList = new ArrayList<>();
848         for (String recipientHeader : RECIPIENT_HEADERS) {
849             final String recipientHeaderValue = mimeMessage.getHeader(recipientHeader, ",");
850             if (recipientHeaderValue != null) {
851                 // parse headers in non strict mode
852                 recipientList.addAll(Arrays.asList(InternetAddress.parseHeader(recipientHeaderValue, false)));
853             }
854 
855         }
856         return recipientList;
857     }
858 
859     /**
860      * Send Mime message.
861      *
862      * @param mimeMessage MIME message
863      * @throws IOException        on error
864      * @throws MessagingException on error
865      */
866     public abstract void sendMessage(MimeMessage mimeMessage) throws IOException, MessagingException;
867 
868     /**
869      * Get folder object.
870      * Folder name can be logical names INBOX, Drafts, Trash or calendar,
871      * or a path relative to user base folder or absolute path.
872      *
873      * @param folderPath folder path
874      * @return Folder object
875      * @throws IOException on error
876      */
877     public ExchangeSession.Folder getFolder(String folderPath) throws IOException {
878         Folder folder = internalGetFolder(folderPath);
879         if (isMainCalendar(folderPath)) {
880             Folder taskFolder = internalGetFolder(TASKS);
881             folder.ctag += taskFolder.ctag;
882         }
883         return folder;
884     }
885 
886     protected abstract Folder internalGetFolder(String folderName) throws IOException;
887 
888     /**
889      * Check folder ctag and reload messages as needed.
890      *
891      * @param currentFolder current folder
892      * @return true if folder changed
893      * @throws IOException on error
894      */
895     public boolean refreshFolder(Folder currentFolder) throws IOException {
896         Folder newFolder = getFolder(currentFolder.folderPath);
897         if (currentFolder.ctag == null || !currentFolder.ctag.equals(newFolder.ctag)
898                 // ctag stamp is limited to second, check message count
899                 || !(currentFolder.messageCount == newFolder.messageCount)
900         ) {
901             if (LOGGER.isDebugEnabled()) {
902                 LOGGER.debug("Contenttag or count changed on " + currentFolder.folderPath +
903                         " ctag: " + currentFolder.ctag + " => " + newFolder.ctag +
904                         " count: " + currentFolder.messageCount + " => " + newFolder.messageCount
905                         + ", reloading messages");
906             }
907             currentFolder.hasChildren = newFolder.hasChildren;
908             currentFolder.noInferiors = newFolder.noInferiors;
909             currentFolder.unreadCount = newFolder.unreadCount;
910             currentFolder.ctag = newFolder.ctag;
911             currentFolder.etag = newFolder.etag;
912             if (newFolder.uidNext > currentFolder.uidNext) {
913                 currentFolder.uidNext = newFolder.uidNext;
914             }
915             currentFolder.loadMessages();
916             return true;
917         } else {
918             return false;
919         }
920     }
921 
922     /**
923      * Create Exchange message folder.
924      *
925      * @param folderName logical folder name
926      * @throws IOException on error
927      */
928     public void createMessageFolder(String folderName) throws IOException {
929         createFolder(folderName, "IPF.Note", null);
930     }
931 
932     /**
933      * Create Exchange calendar folder.
934      *
935      * @param folderName logical folder name
936      * @param properties folder properties
937      * @return status
938      * @throws IOException on error
939      */
940     public int createCalendarFolder(String folderName, Map<String, String> properties) throws IOException {
941         return createFolder(folderName, "IPF.Appointment", properties);
942     }
943 
944     /**
945      * Create Exchange contact folder.
946      *
947      * @param folderName logical folder name
948      * @param properties folder properties
949      * @throws IOException on error
950      */
951     public void createContactFolder(String folderName, Map<String, String> properties) throws IOException {
952         createFolder(folderName, "IPF.Contact", properties);
953     }
954 
955     /**
956      * Create Exchange folder with given folder class.
957      *
958      * @param folderName  logical folder name
959      * @param folderClass folder class
960      * @param properties  folder properties
961      * @return status
962      * @throws IOException on error
963      */
964     public abstract int createFolder(String folderName, String folderClass, Map<String, String> properties) throws IOException;
965 
966     /**
967      * Update Exchange folder properties.
968      *
969      * @param folderName logical folder name
970      * @param properties folder properties
971      * @return status
972      * @throws IOException on error
973      */
974     public abstract int updateFolder(String folderName, Map<String, String> properties) throws IOException;
975 
976     /**
977      * Delete Exchange folder.
978      *
979      * @param folderName logical folder name
980      * @throws IOException on error
981      */
982     public abstract void deleteFolder(String folderName) throws IOException;
983 
984     /**
985      * Copy message to target folder
986      *
987      * @param message      Exchange message
988      * @param targetFolder target folder
989      * @throws IOException on error
990      */
991     public abstract void copyMessage(Message message, String targetFolder) throws IOException;
992 
993     public void copyMessages(List<Message> messages, String targetFolder) throws IOException {
994         for (Message message : messages) {
995             copyMessage(message, targetFolder);
996         }
997     }
998 
999 
1000     /**
1001      * Move message to target folder
1002      *
1003      * @param message      Exchange message
1004      * @param targetFolder target folder
1005      * @throws IOException on error
1006      */
1007     public abstract void moveMessage(Message message, String targetFolder) throws IOException;
1008 
1009     public void moveMessages(List<Message> messages, String targetFolder) throws IOException {
1010         for (Message message : messages) {
1011             moveMessage(message, targetFolder);
1012         }
1013     }
1014 
1015     /**
1016      * Move folder to target name.
1017      *
1018      * @param folderName current folder name/path
1019      * @param targetName target folder name/path
1020      * @throws IOException on error
1021      */
1022     public abstract void moveFolder(String folderName, String targetName) throws IOException;
1023 
1024     /**
1025      * Move item from source path to target path.
1026      *
1027      * @param sourcePath item source path
1028      * @param targetPath item target path
1029      * @throws IOException on error
1030      */
1031     public abstract void moveItem(String sourcePath, String targetPath) throws IOException;
1032 
1033     protected abstract void moveToTrash(Message message) throws IOException;
1034 
1035     /**
1036      * Convert keyword value to IMAP flag.
1037      *
1038      * @param value keyword value
1039      * @return IMAP flag
1040      */
1041     public String convertKeywordToFlag(String value) {
1042         // first test for keyword in settings
1043         Properties flagSettings = Settings.getSubProperties("davmail.imapFlags");
1044         Enumeration<?> flagSettingsEnum = flagSettings.propertyNames();
1045         while (flagSettingsEnum.hasMoreElements()) {
1046             String key = (String) flagSettingsEnum.nextElement();
1047             if (value.equalsIgnoreCase(flagSettings.getProperty(key))) {
1048                 return key;
1049             }
1050         }
1051 
1052         ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags");
1053         Enumeration<String> flagBundleEnum = flagBundle.getKeys();
1054         while (flagBundleEnum.hasMoreElements()) {
1055             String key = flagBundleEnum.nextElement();
1056             if (value.equalsIgnoreCase(flagBundle.getString(key))) {
1057                 return key;
1058             }
1059         }
1060 
1061         // fall back to raw value
1062         return value;
1063     }
1064 
1065     /**
1066      * Convert IMAP flag to keyword value.
1067      *
1068      * @param value IMAP flag
1069      * @return keyword value
1070      */
1071     public String convertFlagToKeyword(String value) {
1072         // first test for flag in settings
1073         Properties flagSettings = Settings.getSubProperties("davmail.imapFlags");
1074         // case insensitive lookup
1075         for (String key : flagSettings.stringPropertyNames()) {
1076             if (key.equalsIgnoreCase(value)) {
1077                 return flagSettings.getProperty(key);
1078             }
1079         }
1080 
1081         // fall back to predefined flags
1082         ResourceBundle flagBundle = ResourceBundle.getBundle("imapflags");
1083         for (String key : flagBundle.keySet()) {
1084             if (key.equalsIgnoreCase(value)) {
1085                 return flagBundle.getString(key);
1086             }
1087         }
1088 
1089         // fall back to raw value
1090         return value;
1091     }
1092 
1093     /**
1094      * Convert IMAP flags to keyword value.
1095      *
1096      * @param flags IMAP flags
1097      * @return keyword value
1098      */
1099     public String convertFlagsToKeywords(HashSet<String> flags) {
1100         HashSet<String> keywordSet = new HashSet<>();
1101         for (String flag : flags) {
1102             keywordSet.add(decodeKeyword(convertFlagToKeyword(flag)));
1103         }
1104         return StringUtil.join(keywordSet, ",");
1105     }
1106 
1107     protected String decodeKeyword(String keyword) {
1108         String result = keyword;
1109         if (keyword.contains("_x0028_") || keyword.contains("_x0029_")) {
1110             result = result.replaceAll("_x0028_", "(")
1111                     .replaceAll("_x0029_", ")");
1112         }
1113         return result;
1114     }
1115 
1116     protected String encodeKeyword(String keyword) {
1117         String result = keyword;
1118         if (keyword.indexOf('(') >= 0|| keyword.indexOf(')') >= 0) {
1119             result = result.replaceAll("\\(", "_x0028_")
1120                     .replaceAll("\\)", "_x0029_" );
1121         }
1122         return result;
1123     }
1124 
1125     /**
1126      * Exchange folder with IMAP properties
1127      */
1128     public class Folder {
1129         /**
1130          * Logical (IMAP) folder path.
1131          */
1132         public String folderPath;
1133 
1134         /**
1135          * Display Name.
1136          */
1137         public String displayName;
1138         /**
1139          * Folder class (PR_CONTAINER_CLASS).
1140          */
1141         public String folderClass;
1142         /**
1143          * Folder message count.
1144          */
1145         public int messageCount;
1146         /**
1147          * Folder unread message count.
1148          */
1149         public int unreadCount;
1150         /**
1151          * true if folder has subfolders (DAV:hassubs).
1152          */
1153         public boolean hasChildren;
1154         /**
1155          * true if folder has no subfolders (DAV:nosubs).
1156          */
1157         public boolean noInferiors;
1158         /**
1159          * Folder content tag (to detect folder content changes).
1160          */
1161         public String ctag;
1162         /**
1163          * Folder etag (to detect folder object changes).
1164          */
1165         public String etag;
1166         /**
1167          * Next IMAP uid
1168          */
1169         public long uidNext;
1170         /**
1171          * recent count
1172          */
1173         public int recent;
1174 
1175         /**
1176          * Folder message list, empty before loadMessages call.
1177          */
1178         public ExchangeSession.MessageList messages;
1179         /**
1180          * Permanent uid (PR_SEARCH_KEY) to IMAP UID map.
1181          */
1182         private final HashMap<String, Long> permanentUrlToImapUidMap = new HashMap<>();
1183 
1184         /**
1185          * Get IMAP folder flags.
1186          *
1187          * @return folder flags in IMAP format
1188          */
1189         public String getFlags() {
1190             String specialFlag = "";
1191             if (isSpecial()) {
1192                 specialFlag = "\\" + folderPath + " ";
1193             }
1194             if (noInferiors) {
1195                 return specialFlag + "\\NoInferiors";
1196             } else if (hasChildren) {
1197                 return specialFlag + "\\HasChildren";
1198             } else {
1199                 return specialFlag + "\\HasNoChildren";
1200             }
1201         }
1202 
1203         /**
1204          * Special folder flag (Sent, Drafts, Trash, Junk).
1205          * @return true if folder is special
1206          */
1207         public boolean isSpecial() {
1208             return SPECIAL.contains(folderPath);
1209         }
1210 
1211         /**
1212          * Load folder messages.
1213          *
1214          * @throws IOException on error
1215          */
1216         public void loadMessages() throws IOException {
1217             messages = ExchangeSession.this.searchMessages(folderPath, null);
1218             fixUids(messages);
1219             recent = 0;
1220             for (Message message : messages) {
1221                 if (message.recent) {
1222                     recent++;
1223                 }
1224             }
1225             long computedUidNext = 1;
1226             if (!messages.isEmpty()) {
1227                 computedUidNext = messages.get(messages.size() - 1).getImapUid() + 1;
1228             }
1229             if (computedUidNext > uidNext) {
1230                 uidNext = computedUidNext;
1231             }
1232         }
1233 
1234         /**
1235          * Search messages in folder matching query.
1236          *
1237          * @param condition search query
1238          * @return message list
1239          * @throws IOException on error
1240          */
1241         public MessageList searchMessages(Condition condition) throws IOException {
1242             MessageList localMessages = ExchangeSession.this.searchMessages(folderPath, condition);
1243             fixUids(localMessages);
1244             return localMessages;
1245         }
1246 
1247         /**
1248          * Restore previous uids changed by a PROPPATCH (flag change).
1249          *
1250          * @param messages message list
1251          */
1252         protected void fixUids(MessageList messages) {
1253             boolean sortNeeded = false;
1254             for (Message message : messages) {
1255                 if (permanentUrlToImapUidMap.containsKey(message.getPermanentId())) {
1256                     long previousUid = permanentUrlToImapUidMap.get(message.getPermanentId());
1257                     if (message.getImapUid() != previousUid) {
1258                         LOGGER.debug("Restoring IMAP uid " + message.getImapUid() + " -> " + previousUid + " for message " + message.getPermanentId());
1259                         message.setImapUid(previousUid);
1260                         sortNeeded = true;
1261                     }
1262                 } else {
1263                     // add message to uid map
1264                     permanentUrlToImapUidMap.put(message.getPermanentId(), message.getImapUid());
1265                 }
1266             }
1267             if (sortNeeded) {
1268                 Collections.sort(messages);
1269             }
1270         }
1271 
1272         /**
1273          * Folder message count.
1274          *
1275          * @return message count
1276          */
1277         public int count() {
1278             if (messages == null) {
1279                 return messageCount;
1280             } else {
1281                 return messages.size();
1282             }
1283         }
1284 
1285         /**
1286          * Compute IMAP uidnext.
1287          *
1288          * @return max(messageuids)+1
1289          */
1290         public long getUidNext() {
1291             return uidNext;
1292         }
1293 
1294         /**
1295          * Get message at index.
1296          *
1297          * @param index message index
1298          * @return message
1299          */
1300         public Message get(int index) {
1301             return messages.get(index);
1302         }
1303 
1304         /**
1305          * Get current folder messages imap uids and flags
1306          *
1307          * @return imap uid list
1308          */
1309         public TreeMap<Long, String> getImapFlagMap() {
1310             TreeMap<Long, String> imapFlagMap = new TreeMap<>();
1311             for (ExchangeSession.Message message : messages) {
1312                 imapFlagMap.put(message.getImapUid(), message.getImapFlags());
1313             }
1314             return imapFlagMap;
1315         }
1316 
1317         /**
1318          * Calendar folder flag.
1319          *
1320          * @return true if this is a calendar folder
1321          */
1322         public boolean isCalendar() {
1323             return "IPF.Appointment".equals(folderClass);
1324         }
1325 
1326         /**
1327          * Contact folder flag.
1328          *
1329          * @return true if this is a calendar folder
1330          */
1331         public boolean isContact() {
1332             return "IPF.Contact".equals(folderClass);
1333         }
1334 
1335         /**
1336          * Task folder flag.
1337          *
1338          * @return true if this is a task folder
1339          */
1340         public boolean isTask() {
1341             return "IPF.Task".equals(folderClass);
1342         }
1343 
1344         /**
1345          * drop cached message
1346          */
1347         public void clearCache() {
1348             messages.cachedMimeContent = null;
1349             messages.cachedMimeMessage = null;
1350             messages.cachedMessageImapUid = 0;
1351         }
1352     }
1353 
1354     /**
1355      * Exchange message.
1356      */
1357     public abstract class Message implements Comparable<Message> {
1358         /**
1359          * enclosing message list
1360          */
1361         public MessageList messageList;
1362         /**
1363          * Message url.
1364          */
1365         public String messageUrl;
1366         /**
1367          * Message permanent url (does not change on message move).
1368          */
1369         public String permanentUrl;
1370         /**
1371          * Message uid.
1372          */
1373         public String uid;
1374         /**
1375          * Message content class.
1376          */
1377         public String contentClass;
1378         /**
1379          * Message keywords (categories).
1380          */
1381         public String keywords;
1382         /**
1383          * Message IMAP uid, unique in folder (x0e230003).
1384          */
1385         public long imapUid;
1386         /**
1387          * MAPI message size.
1388          */
1389         public int size;
1390         /**
1391          * Message date (urn:schemas:mailheader:date).
1392          */
1393         public String date;
1394 
1395         /**
1396          * Message flag: read.
1397          */
1398         public boolean read;
1399         /**
1400          * Message flag: deleted.
1401          */
1402         public boolean deleted;
1403         /**
1404          * Message flag: junk.
1405          */
1406         public boolean junk;
1407         /**
1408          * Message flag: flagged.
1409          */
1410         public boolean flagged;
1411         /**
1412          * Message flag: recent.
1413          */
1414         public boolean recent;
1415         /**
1416          * Message flag: draft.
1417          */
1418         public boolean draft;
1419         /**
1420          * Message flag: answered.
1421          */
1422         public boolean answered;
1423         /**
1424          * Message flag: fowarded.
1425          */
1426         public boolean forwarded;
1427 
1428         /**
1429          * Unparsed message content.
1430          */
1431         protected byte[] mimeContent;
1432 
1433         /**
1434          * Message content parsed in a MIME message.
1435          */
1436         protected MimeMessage mimeMessage;
1437 
1438         /**
1439          * Get permanent message id.
1440          * permanentUrl over WebDav or ItemId over EWS
1441          *
1442          * @return permanent id
1443          */
1444         public abstract String getPermanentId();
1445 
1446         /**
1447          * IMAP uid , unique in folder (x0e230003)
1448          *
1449          * @return IMAP uid
1450          */
1451         public long getImapUid() {
1452             return imapUid;
1453         }
1454 
1455         /**
1456          * Set IMAP uid.
1457          *
1458          * @param imapUid new uid
1459          */
1460         public void setImapUid(long imapUid) {
1461             this.imapUid = imapUid;
1462         }
1463 
1464         /**
1465          * Exchange uid.
1466          *
1467          * @return uid
1468          */
1469         public String getUid() {
1470             return uid;
1471         }
1472 
1473         /**
1474          * Return message flags in IMAP format.
1475          *
1476          * @return IMAP flags
1477          */
1478         public String getImapFlags() {
1479             StringBuilder buffer = new StringBuilder();
1480             if (read) {
1481                 buffer.append("\\Seen ");
1482             }
1483             if (deleted) {
1484                 buffer.append("\\Deleted ");
1485             }
1486             if (recent) {
1487                 buffer.append("\\Recent ");
1488             }
1489             if (flagged) {
1490                 buffer.append("\\Flagged ");
1491             }
1492             if (junk) {
1493                 buffer.append("Junk ");
1494             }
1495             if (draft) {
1496                 buffer.append("\\Draft ");
1497             }
1498             if (answered) {
1499                 buffer.append("\\Answered ");
1500             }
1501             if (forwarded) {
1502                 buffer.append("$Forwarded ");
1503             }
1504             if (keywords != null) {
1505                 for (String keyword : keywords.split(",")) {
1506                     buffer.append(encodeKeyword(convertKeywordToFlag(keyword))).append(" ");
1507                 }
1508             }
1509             return buffer.toString().trim();
1510         }
1511 
1512         /**
1513          * Load message content in a Mime message
1514          *
1515          * @throws IOException        on error
1516          * @throws MessagingException on error
1517          */
1518         public void loadMimeMessage() throws IOException, MessagingException {
1519             if (mimeMessage == null) {
1520                 // try to get message content from cache
1521                 if (this.imapUid == messageList.cachedMessageImapUid
1522                         // make sure we never return null even with broken 0 uid message
1523                         && messageList.cachedMimeContent != null
1524                         && messageList.cachedMimeMessage != null) {
1525                     mimeContent = messageList.cachedMimeContent;
1526                     mimeMessage = messageList.cachedMimeMessage;
1527                     LOGGER.debug("Got message content for " + imapUid + " from cache");
1528                 } else {
1529                     // load and parse message
1530                     mimeContent = getContent(this);
1531                     mimeMessage = new MimeMessage(null, new SharedByteArrayInputStream(mimeContent));
1532                     // workaround for Exchange 2003 ActiveSync bug
1533                     if (mimeMessage.getHeader("MAIL FROM") != null) {
1534                         // find start of actual message
1535                         byte[] mimeContentCopy = new byte[((SharedByteArrayInputStream) mimeMessage.getRawInputStream()).available()];
1536                         int offset = mimeContent.length - mimeContentCopy.length;
1537                         // remove unwanted header
1538                         System.arraycopy(mimeContent, offset, mimeContentCopy, 0, mimeContentCopy.length);
1539                         mimeContent = mimeContentCopy;
1540                         mimeMessage = new MimeMessage(null, new SharedByteArrayInputStream(mimeContent));
1541                     }
1542                     LOGGER.debug("Downloaded full message content for IMAP UID " + imapUid + " (" + mimeContent.length + " bytes)");
1543                 }
1544             }
1545         }
1546 
1547         /**
1548          * Get message content as a Mime message.
1549          *
1550          * @return mime message
1551          * @throws IOException        on error
1552          * @throws MessagingException on error
1553          */
1554         public MimeMessage getMimeMessage() throws IOException, MessagingException {
1555             loadMimeMessage();
1556             return mimeMessage;
1557         }
1558 
1559         public Enumeration<?> getMatchingHeaderLinesFromHeaders(String[] headerNames) throws MessagingException {
1560             Enumeration<?> result = null;
1561             if (mimeMessage == null) {
1562                 // message not loaded, try to get headers only
1563                 InputStream headers = getMimeHeaders();
1564                 if (headers != null) {
1565                     InternetHeaders internetHeaders = new InternetHeaders(headers);
1566                     if (internetHeaders.getHeader("Subject") == null) {
1567                         // invalid header content
1568                         return null;
1569                     }
1570                     if (headerNames == null) {
1571                         result = internetHeaders.getAllHeaderLines();
1572                     } else {
1573                         result = internetHeaders.getMatchingHeaderLines(headerNames);
1574                     }
1575                 }
1576             }
1577             return result;
1578         }
1579 
1580         public Enumeration<?> getMatchingHeaderLines(String[] headerNames) throws MessagingException, IOException {
1581             Enumeration<?> result = getMatchingHeaderLinesFromHeaders(headerNames);
1582             if (result == null) {
1583                 if (headerNames == null) {
1584                     result = getMimeMessage().getAllHeaderLines();
1585                 } else {
1586                     result = getMimeMessage().getMatchingHeaderLines(headerNames);
1587                 }
1588 
1589             }
1590             return result;
1591         }
1592 
1593         protected abstract InputStream getMimeHeaders();
1594 
1595         /**
1596          * Get message body size.
1597          *
1598          * @return mime message size
1599          * @throws IOException        on error
1600          * @throws MessagingException on error
1601          */
1602         public int getMimeMessageSize() throws IOException, MessagingException {
1603             loadMimeMessage();
1604             return mimeContent.length;
1605         }
1606 
1607         /**
1608          * Get message body input stream.
1609          *
1610          * @return mime message InputStream
1611          * @throws IOException        on error
1612          * @throws MessagingException on error
1613          */
1614         public InputStream getRawInputStream() throws IOException, MessagingException {
1615             loadMimeMessage();
1616             return new SharedByteArrayInputStream(mimeContent);
1617         }
1618 
1619 
1620         /**
1621          * Drop mime message to avoid keeping message content in memory,
1622          * keep a single message in MessageList cache to handle chunked fetch.
1623          */
1624         public void dropMimeMessage() {
1625             // update single message cache
1626             if (mimeMessage != null) {
1627                 messageList.cachedMessageImapUid = imapUid;
1628                 messageList.cachedMimeContent = mimeContent;
1629                 messageList.cachedMimeMessage = mimeMessage;
1630             }
1631             // drop curent message body to save memory
1632             mimeMessage = null;
1633             mimeContent = null;
1634         }
1635 
1636         public boolean isLoaded() {
1637             // check and retrieve cached content
1638             if (imapUid == messageList.cachedMessageImapUid) {
1639                 mimeContent = messageList.cachedMimeContent;
1640                 mimeMessage = messageList.cachedMimeMessage;
1641             }
1642             return mimeMessage != null;
1643         }
1644 
1645         /**
1646          * Delete message.
1647          *
1648          * @throws IOException on error
1649          */
1650         public void delete() throws IOException {
1651             deleteMessage(this);
1652         }
1653 
1654         /**
1655          * Move message to trash, mark message read.
1656          *
1657          * @throws IOException on error
1658          */
1659         public void moveToTrash() throws IOException {
1660             markRead();
1661 
1662             ExchangeSession.this.moveToTrash(this);
1663         }
1664 
1665         /**
1666          * Mark message as read.
1667          *
1668          * @throws IOException on error
1669          */
1670         public void markRead() throws IOException {
1671             HashMap<String, String> properties = new HashMap<>();
1672             properties.put("read", "1");
1673             updateMessage(this, properties);
1674         }
1675 
1676         /**
1677          * Comparator to sort messages by IMAP uid
1678          *
1679          * @param message other message
1680          * @return imapUid comparison result
1681          */
1682         public int compareTo(Message message) {
1683             long compareValue = (imapUid - message.imapUid);
1684             if (compareValue > 0) {
1685                 return 1;
1686             } else if (compareValue < 0) {
1687                 return -1;
1688             } else {
1689                 return 0;
1690             }
1691         }
1692 
1693         /**
1694          * Override equals, compare IMAP uids
1695          *
1696          * @param message other message
1697          * @return true if IMAP uids are equal
1698          */
1699         @Override
1700         public boolean equals(Object message) {
1701             return message instanceof Message && imapUid == ((Message) message).imapUid;
1702         }
1703 
1704         /**
1705          * Override hashCode, return imapUid hashcode.
1706          *
1707          * @return imapUid hashcode
1708          */
1709         @Override
1710         public int hashCode() {
1711             return Long.hashCode(imapUid);
1712         }
1713 
1714         public String removeFlag(String flag) {
1715             if (keywords != null) {
1716                 final String exchangeFlag = convertFlagToKeyword(flag);
1717                 Set<String> keywordSet = new HashSet<>();
1718                 String[] keywordArray = keywords.split(",");
1719                 for (String value : keywordArray) {
1720                     if (!value.equalsIgnoreCase(exchangeFlag)) {
1721                         keywordSet.add(value);
1722                     }
1723                 }
1724                 keywords = StringUtil.join(keywordSet, ",");
1725             }
1726             return keywords;
1727         }
1728 
1729         public String addFlag(String flag) {
1730             final String exchangeFlag = convertFlagToKeyword(flag);
1731             HashSet<String> keywordSet = new HashSet<>();
1732             boolean hasFlag = false;
1733             if (keywords != null) {
1734                 String[] keywordArray = keywords.split(",");
1735                 for (String value : keywordArray) {
1736                     keywordSet.add(value);
1737                     if (value.equalsIgnoreCase(exchangeFlag)) {
1738                         hasFlag = true;
1739                     }
1740                 }
1741             }
1742             if (!hasFlag) {
1743                 keywordSet.add(exchangeFlag);
1744             }
1745             keywords = StringUtil.join(keywordSet, ",");
1746             return keywords;
1747         }
1748 
1749         public String setFlags(HashSet<String> flags) {
1750             keywords = convertFlagsToKeywords(flags);
1751             return keywords;
1752         }
1753 
1754     }
1755 
1756     /**
1757      * Message list, includes a single messsage cache
1758      */
1759     public static class MessageList extends ArrayList<Message> {
1760         /**
1761          * Cached message content parsed in a MIME message.
1762          */
1763         protected transient MimeMessage cachedMimeMessage;
1764         /**
1765          * Cached message uid.
1766          */
1767         protected transient long cachedMessageImapUid;
1768         /**
1769          * Cached unparsed message
1770          */
1771         protected transient byte[] cachedMimeContent;
1772 
1773     }
1774 
1775     /**
1776      * Generic folder item.
1777      */
1778     public abstract static class Item extends HashMap<String, String> {
1779         public String folderPath;
1780         protected String itemName;
1781         protected String permanentUrl;
1782         /**
1783          * Display name.
1784          */
1785         public String displayName;
1786         /**
1787          * item etag
1788          */
1789         public String etag;
1790         protected String noneMatch;
1791 
1792         /**
1793          * Build item instance.
1794          *
1795          * @param folderPath folder path
1796          * @param itemName   item name class
1797          * @param etag       item etag
1798          * @param noneMatch  none match flag
1799          */
1800         public Item(String folderPath, String itemName, String etag, String noneMatch) {
1801             this.folderPath = folderPath;
1802             this.itemName = itemName;
1803             this.etag = etag;
1804             this.noneMatch = noneMatch;
1805         }
1806 
1807         /**
1808          * Default constructor.
1809          */
1810         protected Item() {
1811         }
1812 
1813         /**
1814          * Return item content type
1815          *
1816          * @return content type
1817          */
1818         public abstract String getContentType();
1819 
1820         /**
1821          * Retrieve item body from Exchange
1822          *
1823          * @return item body
1824          * @throws IOException on error
1825          */
1826         public abstract String getBody() throws IOException;
1827 
1828         /**
1829          * Get event name (file name part in URL).
1830          *
1831          * @return event name
1832          */
1833         public String getName() {
1834             return itemName;
1835         }
1836 
1837         /**
1838          * Get event etag (last change tag).
1839          *
1840          * @return event etag
1841          */
1842         public String getEtag() {
1843             return etag;
1844         }
1845 
1846         /**
1847          * Set item href.
1848          *
1849          * @param href item href
1850          */
1851         public void setHref(String href) {
1852             int index = href.lastIndexOf('/');
1853             if (index >= 0) {
1854                 folderPath = href.substring(0, index);
1855                 itemName = href.substring(index + 1);
1856             } else {
1857                 throw new IllegalArgumentException(href);
1858             }
1859         }
1860 
1861         /**
1862          * Return item href.
1863          *
1864          * @return item href
1865          */
1866         public String getHref() {
1867             return folderPath + '/' + itemName;
1868         }
1869 
1870         public void setItemName(String itemName) {
1871             this.itemName = itemName;
1872         }
1873     }
1874 
1875     /**
1876      * Contact object
1877      */
1878     public abstract class Contact extends Item {
1879 
1880         protected ArrayList<String> distributionListMembers = null;
1881         protected String vCardVersion;
1882 
1883         public Contact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) {
1884             super(folderPath, itemName.endsWith(".vcf") ? itemName.substring(0, itemName.length() - 3) + "EML" : itemName, etag, noneMatch);
1885             this.putAll(properties);
1886         }
1887 
1888         protected Contact() {
1889         }
1890 
1891         public void setVCardVersion(String vCardVersion) {
1892             this.vCardVersion = vCardVersion;
1893         }
1894 
1895         public abstract ItemResult createOrUpdate() throws IOException;
1896 
1897         /**
1898          * Convert EML extension to vcf.
1899          *
1900          * @return item name
1901          */
1902         @Override
1903         public String getName() {
1904             String name = super.getName();
1905             if (name.endsWith(".EML")) {
1906                 name = name.substring(0, name.length() - 3) + "vcf";
1907             }
1908             return name;
1909         }
1910 
1911         /**
1912          * Set contact name
1913          *
1914          * @param name contact name
1915          */
1916         public void setName(String name) {
1917             this.itemName = name;
1918         }
1919 
1920         /**
1921          * Compute vcard uid from name.
1922          *
1923          * @return uid
1924          */
1925         public String getUid() {
1926             String uid = getName();
1927             int dotIndex = uid.lastIndexOf('.');
1928             if (dotIndex > 0) {
1929                 uid = uid.substring(0, dotIndex);
1930             }
1931             return URIUtil.encodePath(uid);
1932         }
1933 
1934         @Override
1935         public String getContentType() {
1936             return "text/vcard";
1937         }
1938 
1939         public void addMember(String member) {
1940             if (distributionListMembers == null) {
1941                 distributionListMembers = new ArrayList<>();
1942             }
1943             distributionListMembers.add(member);
1944         }
1945 
1946 
1947         @Override
1948         public String getBody() {
1949             // build RFC 2426 VCard from contact information
1950             VCardWriter writer = new VCardWriter();
1951             writer.startCard(vCardVersion);
1952             writer.appendProperty("UID", getUid());
1953             // common name
1954             String cn = get("cn");
1955             if (cn == null) {
1956                 cn = get("displayname");
1957             }
1958             String sn = get("sn");
1959             if (sn == null) {
1960                 sn = cn;
1961             }
1962             writer.appendProperty("FN", cn);
1963             // RFC 2426: Family Name, Given Name, Additional Names, Honorific Prefixes, and Honorific Suffixes
1964             writer.appendProperty("N", sn, get("givenName"), get("middlename"), get("personaltitle"), get("namesuffix"));
1965 
1966             if (distributionListMembers != null) {
1967                 writer.appendProperty("KIND", "group");
1968                 for (String member : distributionListMembers) {
1969                     writer.appendProperty("MEMBER", member);
1970                 }
1971             }
1972 
1973             writer.appendProperty("TEL;TYPE=cell", get("mobile"));
1974             writer.appendProperty("TEL;TYPE=work", get("telephoneNumber"));
1975             writer.appendProperty("TEL;TYPE=home", get("homePhone"));
1976             writer.appendProperty("TEL;TYPE=fax", get("facsimiletelephonenumber"));
1977             writer.appendProperty("TEL;TYPE=pager", get("pager"));
1978             writer.appendProperty("TEL;TYPE=car", get("othermobile"));
1979             writer.appendProperty("TEL;TYPE=home,fax", get("homefax"));
1980             writer.appendProperty("TEL;TYPE=isdn", get("internationalisdnnumber"));
1981             writer.appendProperty("TEL;TYPE=msg", get("otherTelephone"));
1982 
1983             // The structured type value corresponds, in sequence, to the post office box; the extended address;
1984             // the street address; the locality (e.g., city); the region (e.g., state or province);
1985             // the postal code; the country name
1986             writer.appendProperty("ADR;TYPE=home",
1987                     get("homepostofficebox"), null, get("homeStreet"), get("homeCity"), get("homeState"), get("homePostalCode"), get("homeCountry"));
1988             writer.appendProperty("ADR;TYPE=work",
1989                     get("postofficebox"), get("roomnumber"), get("street"), get("l"), get("st"), get("postalcode"), get("co"));
1990             writer.appendProperty("ADR;TYPE=other",
1991                     get("otherpostofficebox"), null, get("otherstreet"), get("othercity"), get("otherstate"), get("otherpostalcode"), get("othercountry"));
1992 
1993             writer.appendProperty("EMAIL;TYPE=work", get("smtpemail1"));
1994             writer.appendProperty("EMAIL;TYPE=home", get("smtpemail2"));
1995             writer.appendProperty("EMAIL;TYPE=other", get("smtpemail3"));
1996 
1997             writer.appendProperty("ORG", get("o"), get("department"));
1998             writer.appendProperty("URL;TYPE=work", get("businesshomepage"));
1999             writer.appendProperty("URL;TYPE=home", get("personalHomePage"));
2000             writer.appendProperty("TITLE", get("title"));
2001             writer.appendProperty("NOTE", get("description"));
2002 
2003             writer.appendProperty("CUSTOM1", get("extensionattribute1"));
2004             writer.appendProperty("CUSTOM2", get("extensionattribute2"));
2005             writer.appendProperty("CUSTOM3", get("extensionattribute3"));
2006             writer.appendProperty("CUSTOM4", get("extensionattribute4"));
2007 
2008             writer.appendProperty("ROLE", get("profession"));
2009             writer.appendProperty("NICKNAME", get("nickname"));
2010             writer.appendProperty("X-AIM", get("im"));
2011 
2012             writer.appendProperty("BDAY", convertZuluDateToBday(get("bday")));
2013             writer.appendProperty("ANNIVERSARY", convertZuluDateToBday(get("anniversary")));
2014 
2015             String gender = get("gender");
2016             if ("1".equals(gender)) {
2017                 writer.appendProperty("SEX", "2");
2018             } else if ("2".equals(gender)) {
2019                 writer.appendProperty("SEX", "1");
2020             }
2021 
2022             writer.appendProperty("CATEGORIES", get("keywords"));
2023 
2024             writer.appendProperty("FBURL", get("fburl"));
2025 
2026             if ("1".equals(get("private"))) {
2027                 writer.appendProperty("CLASS", "PRIVATE");
2028             }
2029 
2030             writer.appendProperty("X-ASSISTANT", get("secretarycn"));
2031             writer.appendProperty("X-MANAGER", get("manager"));
2032             writer.appendProperty("X-SPOUSE", get("spousecn"));
2033 
2034             writer.appendProperty("REV", get("lastmodified"));
2035 
2036             ContactPhoto contactPhoto = null;
2037 
2038             if (Settings.getBooleanProperty("davmail.carddavReadPhoto", true)) {
2039                 if (("true".equals(get("haspicture")))) {
2040                     try {
2041                         contactPhoto = getContactPhoto(this);
2042                     } catch (IOException e) {
2043                         LOGGER.warn("Unable to get photo from contact " + this.get("cn"));
2044                     }
2045                 }
2046 
2047                 if (contactPhoto == null) {
2048                     contactPhoto = getADPhoto(get("smtpemail1"));
2049                 }
2050             }
2051 
2052             if (contactPhoto != null) {
2053                 writer.writeLine("PHOTO:data:"+contactPhoto.contentType+";base64," +contactPhoto.content);
2054             }
2055 
2056             writer.appendProperty("KEY1;X509;ENCODING=BASE64", get("msexchangecertificate"));
2057             writer.appendProperty("KEY2;X509;ENCODING=BASE64", get("usersmimecertificate"));
2058 
2059             writer.endCard();
2060             return writer.toString();
2061         }
2062     }
2063 
2064     /**
2065      * Calendar event object.
2066      */
2067     public abstract class Event extends Item {
2068         protected String contentClass;
2069         protected String subject;
2070         protected VCalendar vCalendar;
2071 
2072         public Event(String folderPath, String itemName, String contentClass, String itemBody, String etag, String noneMatch) throws IOException {
2073             super(folderPath, itemName, etag, noneMatch);
2074             this.contentClass = contentClass;
2075             fixICS(itemBody.getBytes(StandardCharsets.UTF_8), getCalendarEmail(folderPath), false);
2076             // fix task item name
2077             if (vCalendar.isTodo() && this.itemName.endsWith(".ics")) {
2078                 this.itemName = itemName.substring(0, itemName.length() - 3) + "EML";
2079             }
2080         }
2081 
2082         protected Event() {
2083         }
2084 
2085         @Override
2086         public String getContentType() {
2087             return "text/calendar;charset=UTF-8";
2088         }
2089 
2090         @Override
2091         public String getBody() throws IOException {
2092             if (vCalendar == null) {
2093                 fixICS(getEventContent(), getCalendarEmail(folderPath), true);
2094             }
2095             return vCalendar.toString();
2096         }
2097 
2098         protected HttpNotFoundException buildHttpNotFoundException(Exception e) {
2099             String message = "Unable to get event " + getName() + " subject: " + subject + " at " + permanentUrl + ": " + e.getMessage();
2100             LOGGER.warn(message);
2101             return new HttpNotFoundException(message);
2102         }
2103 
2104         /**
2105          * Retrieve item body from Exchange
2106          *
2107          * @return item content
2108          * @throws IOException on error
2109          */
2110         public abstract byte[] getEventContent() throws IOException;
2111 
2112         protected static final String TEXT_CALENDAR = "text/calendar";
2113         protected static final String APPLICATION_ICS = "application/ics";
2114 
2115         protected boolean isCalendarContentType(String contentType) {
2116             return TEXT_CALENDAR.regionMatches(true, 0, contentType, 0, TEXT_CALENDAR.length()) ||
2117                     APPLICATION_ICS.regionMatches(true, 0, contentType, 0, APPLICATION_ICS.length());
2118         }
2119 
2120         protected MimePart getCalendarMimePart(MimeMultipart multiPart) throws IOException, MessagingException {
2121             MimePart bodyPart = null;
2122             for (int i = 0; i < multiPart.getCount(); i++) {
2123                 String contentType = multiPart.getBodyPart(i).getContentType();
2124                 if (isCalendarContentType(contentType)) {
2125                     bodyPart = (MimePart) multiPart.getBodyPart(i);
2126                     break;
2127                 } else if (contentType.startsWith("multipart")) {
2128                     Object content = multiPart.getBodyPart(i).getContent();
2129                     if (content instanceof MimeMultipart) {
2130                         bodyPart = getCalendarMimePart((MimeMultipart) content);
2131                     }
2132                 }
2133             }
2134 
2135             return bodyPart;
2136         }
2137 
2138         /**
2139          * Load ICS content from MIME message input stream
2140          *
2141          * @param mimeInputStream mime message input stream
2142          * @return mime message ics attachment body
2143          * @throws IOException        on error
2144          * @throws MessagingException on error
2145          */
2146         protected byte[] getICS(InputStream mimeInputStream) throws IOException, MessagingException {
2147             byte[] result;
2148             MimeMessage mimeMessage = new MimeMessage(null, mimeInputStream);
2149             String[] contentClassHeader = mimeMessage.getHeader("Content-class");
2150             // task item, return null
2151             if (contentClassHeader != null && contentClassHeader.length > 0 && "urn:content-classes:task".equals(contentClassHeader[0])) {
2152                 return null;
2153             }
2154             Object mimeBody = mimeMessage.getContent();
2155             MimePart bodyPart = null;
2156             if (mimeBody instanceof MimeMultipart) {
2157                 bodyPart = getCalendarMimePart((MimeMultipart) mimeBody);
2158             } else if (isCalendarContentType(mimeMessage.getContentType())) {
2159                 // no multipart, single body
2160                 bodyPart = mimeMessage;
2161             }
2162 
2163 
2164             if (bodyPart != null) {
2165                 try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
2166                     bodyPart.getDataHandler().writeTo(baos);
2167                     result = baos.toByteArray();
2168                 }
2169             } else {
2170                 try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
2171                     mimeMessage.writeTo(baos);
2172                     throw new DavMailException("EXCEPTION_INVALID_MESSAGE_CONTENT", new String(baos.toByteArray(), StandardCharsets.UTF_8));
2173                 }
2174             }
2175             return result;
2176         }
2177 
2178         protected void fixICS(byte[] icsContent, String calendarEmail, boolean fromServer) throws IOException {
2179             if (LOGGER.isDebugEnabled() && fromServer) {
2180                 dumpIndex++;
2181                 String icsBody = new String(icsContent, StandardCharsets.UTF_8);
2182                 ICSCalendarValidator.ValidationResult vr = ICSCalendarValidator.validateWithDetails(icsBody);
2183                 dumpICS(icsBody, true, false);
2184                 LOGGER.debug("Vcalendar body ValidationResult: "+ vr.isValid() +" "+ vr.showReason());
2185                 LOGGER.debug("Vcalendar body received from server:\n" + icsBody);
2186             }
2187             vCalendar = new VCalendar(icsContent, calendarEmail, getVTimezone());
2188             vCalendar.fixVCalendar(fromServer);
2189             if (LOGGER.isDebugEnabled() && !fromServer) {
2190                 String resultString = vCalendar.toString();
2191                 ICSCalendarValidator.ValidationResult vr = ICSCalendarValidator.validateWithDetails(resultString);
2192                 LOGGER.debug("Fixed Vcalendar body ValidationResult: "+ vr.isValid() +" "+ vr.showReason());
2193                 LOGGER.debug("Fixed Vcalendar body to server:\n" + resultString);
2194                 dumpICS(resultString, false, true);
2195             }
2196         }
2197 
2198         protected void dumpICS(String icsBody, boolean fromServer, boolean after) {
2199             String logFileDirectory = Settings.getLogFileDirectory();
2200 
2201             // additional setting to activate ICS dump (not available in GUI)
2202             int dumpMax = Settings.getIntProperty("davmail.dumpICS");
2203             if (dumpMax > 0) {
2204                 if (dumpIndex > dumpMax) {
2205                     // Delete the oldest dump file
2206                     final int oldest = dumpIndex - dumpMax;
2207                     try {
2208                         File[] oldestFiles = (new File(logFileDirectory)).listFiles((dir, name) -> {
2209                             if (name.endsWith(".ics")) {
2210                                 int dashIndex = name.indexOf('-');
2211                                 if (dashIndex > 0) {
2212                                     try {
2213                                         int fileIndex = Integer.parseInt(name.substring(0, dashIndex));
2214                                         return fileIndex < oldest;
2215                                     } catch (NumberFormatException nfe) {
2216                                         // ignore
2217                                     }
2218                                 }
2219                             }
2220                             return false;
2221                         });
2222                         if (oldestFiles != null) {
2223                             for (File file : oldestFiles) {
2224                                 if (!file.delete()) {
2225                                     LOGGER.warn("Unable to delete " + file.getAbsolutePath());
2226                                 }
2227                             }
2228                         }
2229                     } catch (Exception ex) {
2230                         LOGGER.warn("Error deleting ics dump: " + ex.getMessage());
2231                     }
2232                 }
2233 
2234                 StringBuilder filePath = new StringBuilder();
2235                 filePath.append(logFileDirectory).append('/')
2236                         .append(dumpIndex)
2237                         .append(after ? "-to" : "-from")
2238                         .append((after ^ fromServer) ? "-server" : "-client")
2239                         .append(".ics");
2240                 if ((icsBody != null) && (!icsBody.isEmpty())) {
2241                     try (OutputStreamWriter writer = new OutputStreamWriter(Files.newOutputStream(Paths.get(filePath.toString())), StandardCharsets.UTF_8))
2242                     {
2243                         writer.write(icsBody);
2244                     } catch (IOException e) {
2245                         LOGGER.error(e);
2246                     }
2247 
2248 
2249                 }
2250             }
2251 
2252         }
2253 
2254         /**
2255          * Build Mime body for event or event message.
2256          *
2257          * @return mimeContent as byte array or null
2258          * @throws IOException on error
2259          */
2260         public byte[] createMimeContent() throws IOException {
2261             String boundary = UUID.randomUUID().toString();
2262             ByteArrayOutputStream baos = new ByteArrayOutputStream();
2263             MimeOutputStreamWriter writer = new MimeOutputStreamWriter(baos);
2264 
2265             writer.writeHeader("Content-Transfer-Encoding", "7bit");
2266             writer.writeHeader("Content-class", contentClass);
2267             // append date
2268             writer.writeHeader("Date", new Date());
2269 
2270             // Make sure invites have a proper subject line
2271             String vEventSubject = vCalendar.getFirstVeventPropertyValue("SUMMARY");
2272             if (vEventSubject == null) {
2273                 vEventSubject = BundleMessage.format("MEETING_REQUEST");
2274             }
2275 
2276             // Write a part of the message that contains the
2277             // ICS description so that invites contain the description text
2278             String description = vCalendar.getFirstVeventPropertyValue("DESCRIPTION");
2279 
2280             // handle notifications
2281             if ("urn:content-classes:calendarmessage".equals(contentClass)) {
2282                 // need to parse attendees and organizer to build recipients
2283                 VCalendar.Recipients recipients = vCalendar.getRecipients(true);
2284                 String to;
2285                 String cc;
2286                 String notificationSubject;
2287                 if (email.equalsIgnoreCase(recipients.organizer)) {
2288                     // current user is organizer => notify all
2289                     to = recipients.attendees;
2290                     cc = recipients.optionalAttendees;
2291                     notificationSubject = subject;
2292                 } else {
2293                     String status = vCalendar.getAttendeeStatus();
2294                     // notify only organizer
2295                     to = recipients.organizer;
2296                     cc = null;
2297                     notificationSubject = (status != null) ? (BundleMessage.format(status) + vEventSubject) : subject;
2298                     description = "";
2299                 }
2300 
2301                 // Allow end user notification edit
2302                 if (Settings.getBooleanProperty("davmail.caldavEditNotifications")) {
2303                     // create notification edit dialog
2304                     NotificationDialog notificationDialog = new NotificationDialog(to,
2305                             cc, notificationSubject, description);
2306                     if (!notificationDialog.getSendNotification()) {
2307                         LOGGER.debug("Notification canceled by user");
2308                         return null;
2309                     }
2310                     // get description from dialog
2311                     to = notificationDialog.getTo();
2312                     cc = notificationDialog.getCc();
2313                     notificationSubject = notificationDialog.getSubject();
2314                     description = notificationDialog.getBody();
2315                 }
2316 
2317                 // do not send notification if no recipients found
2318                 if ((to == null || to.isEmpty()) && (cc == null || cc.isEmpty())) {
2319                     return null;
2320                 }
2321 
2322                 writer.writeHeader("To", to);
2323                 writer.writeHeader("Cc", cc);
2324                 writer.writeHeader("Subject", notificationSubject);
2325 
2326 
2327                 if (LOGGER.isDebugEnabled()) {
2328                     StringBuilder logBuffer = new StringBuilder("Sending notification ");
2329                     if (to != null) {
2330                         logBuffer.append("to: ").append(to);
2331                     }
2332                     if (cc != null) {
2333                         logBuffer.append("cc: ").append(cc);
2334                     }
2335                     LOGGER.debug(logBuffer.toString());
2336                 }
2337             } else {
2338                 // need to parse attendees and organizer to build recipients
2339                 VCalendar.Recipients recipients = vCalendar.getRecipients(false);
2340                 // storing appointment, full recipients header
2341                 if (recipients.attendees != null) {
2342                     writer.writeHeader("To", recipients.attendees);
2343                 } else {
2344                     // use current user as attendee
2345                     writer.writeHeader("To", email);
2346                 }
2347                 writer.writeHeader("Cc", recipients.optionalAttendees);
2348 
2349                 if (recipients.organizer != null) {
2350                     writer.writeHeader("From", recipients.organizer);
2351                 } else {
2352                     writer.writeHeader("From", email);
2353                 }
2354             }
2355             if (vCalendar.getMethod() == null) {
2356                 vCalendar.setPropertyValue("METHOD", "REQUEST");
2357             }
2358             writer.writeHeader("MIME-Version", "1.0");
2359             writer.writeHeader("Content-Type", "multipart/alternative;\r\n" +
2360                     "\tboundary=\"----=_NextPart_" + boundary + '\"');
2361             writer.writeLn();
2362             writer.writeLn("This is a multi-part message in MIME format.");
2363             writer.writeLn();
2364             writer.writeLn("------=_NextPart_" + boundary);
2365 
2366             if (description != null && !description.isEmpty()) {
2367                 writer.writeHeader("Content-Type", "text/plain;\r\n" +
2368                         "\tcharset=\"utf-8\"");
2369                 writer.writeHeader("content-transfer-encoding", "8bit");
2370                 writer.writeLn();
2371                 writer.flush();
2372                 baos.write(description.getBytes(StandardCharsets.UTF_8));
2373                 writer.writeLn();
2374                 writer.writeLn("------=_NextPart_" + boundary);
2375             }
2376             writer.writeHeader("Content-class", contentClass);
2377             writer.writeHeader("Content-Type", "text/calendar;\r\n" +
2378                     "\tmethod=" + vCalendar.getMethod() + ";\r\n" +
2379                     "\tcharset=\"utf-8\""
2380             );
2381             writer.writeHeader("Content-Transfer-Encoding", "8bit");
2382             writer.writeLn();
2383             writer.flush();
2384             baos.write(vCalendar.toString().getBytes(StandardCharsets.UTF_8));
2385             writer.writeLn();
2386             writer.writeLn("------=_NextPart_" + boundary + "--");
2387             writer.close();
2388             return baos.toByteArray();
2389         }
2390 
2391         /**
2392          * Create or update item
2393          *
2394          * @return action result
2395          * @throws IOException on error
2396          */
2397         public abstract ItemResult createOrUpdate() throws IOException;
2398 
2399     }
2400 
2401     protected abstract Set<String> getItemProperties();
2402 
2403     /**
2404      * Search contacts in provided folder.
2405      *
2406      * @param folderPath Exchange folder path
2407      * @param includeDistList include distribution lists
2408      * @return list of contacts
2409      * @throws IOException on error
2410      */
2411     public List<ExchangeSession.Contact> getAllContacts(String folderPath, boolean includeDistList) throws IOException {
2412         return searchContacts(folderPath, ExchangeSession.CONTACT_ATTRIBUTES, isEqualTo("outlookmessageclass", "IPM.Contact"), 0);
2413     }
2414 
2415 
2416     /**
2417      * Search contacts in provided folder matching the search query.
2418      *
2419      * @param folderPath Exchange folder path
2420      * @param attributes requested attributes
2421      * @param condition  Exchange search query
2422      * @param maxCount   maximum item count
2423      * @return list of contacts
2424      * @throws IOException on error
2425      */
2426     public abstract List<Contact> searchContacts(String folderPath, Set<String> attributes, Condition condition, int maxCount) throws IOException;
2427 
2428     /**
2429      * Search calendar messages in provided folder.
2430      *
2431      * @param folderPath Exchange folder path
2432      * @return list of calendar messages as Event objects
2433      * @throws IOException on error
2434      */
2435     public abstract List<Event> getEventMessages(String folderPath) throws IOException;
2436 
2437     /**
2438      * Search calendar events in provided folder.
2439      *
2440      * @param folderPath Exchange folder path
2441      * @return list of calendar events
2442      * @throws IOException on error
2443      */
2444     public List<Event> getAllEvents(String folderPath) throws IOException {
2445         List<Event> results = searchEvents(folderPath, getCalendarItemCondition(getPastDelayCondition("dtstart")));
2446 
2447         if (!Settings.getBooleanProperty("davmail.caldavDisableTasks", false) && isMainCalendar(folderPath)) {
2448             // retrieve tasks from main tasks folder
2449             results.addAll(searchTasksOnly(TASKS));
2450         }
2451 
2452         return results;
2453     }
2454 
2455     protected abstract Condition getCalendarItemCondition(Condition dateCondition);
2456 
2457     protected Condition getPastDelayCondition(String attribute) {
2458         int caldavPastDelay = Settings.getIntProperty("davmail.caldavPastDelay");
2459         Condition dateCondition = null;
2460         if (caldavPastDelay != 0) {
2461             Calendar cal = Calendar.getInstance();
2462             cal.add(Calendar.DAY_OF_MONTH, -caldavPastDelay);
2463             dateCondition = gt(attribute, formatSearchDate(cal.getTime()));
2464         }
2465         return dateCondition;
2466     }
2467 
2468     protected Condition getRangeCondition(String timeRangeStart, String timeRangeEnd) throws IOException {
2469         try {
2470             SimpleDateFormat parser = getZuluDateFormat();
2471             ExchangeSession.MultiCondition andCondition = and();
2472             if (timeRangeStart != null) {
2473                 andCondition.add(gt("dtend", formatSearchDate(parser.parse(timeRangeStart))));
2474             }
2475             if (timeRangeEnd != null) {
2476                 andCondition.add(lt("dtstart", formatSearchDate(parser.parse(timeRangeEnd))));
2477             }
2478             return andCondition;
2479         } catch (ParseException e) {
2480             throw new IOException(e + " " + e.getMessage());
2481         }
2482     }
2483 
2484     /**
2485      * Search events between start and end.
2486      *
2487      * @param folderPath     Exchange folder path
2488      * @param timeRangeStart date range start in zulu format
2489      * @param timeRangeEnd   date range start in zulu format
2490      * @return list of calendar events
2491      * @throws IOException on error
2492      */
2493     public List<Event> searchEvents(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException {
2494         Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd);
2495         Condition condition = getCalendarItemCondition(dateCondition);
2496 
2497         return searchEvents(folderPath, condition);
2498     }
2499 
2500     /**
2501      * Search events between start and end, exclude tasks.
2502      *
2503      * @param folderPath     Exchange folder path
2504      * @param timeRangeStart date range start in zulu format
2505      * @param timeRangeEnd   date range start in zulu format
2506      * @return list of calendar events
2507      * @throws IOException on error
2508      */
2509     public List<Event> searchEventsOnly(String folderPath, String timeRangeStart, String timeRangeEnd) throws IOException {
2510         Condition dateCondition = getRangeCondition(timeRangeStart, timeRangeEnd);
2511         return searchEvents(folderPath, getCalendarItemCondition(dateCondition));
2512     }
2513 
2514     /**
2515      * Search tasks only (VTODO).
2516      *
2517      * @param folderPath Exchange folder path
2518      * @return list of tasks
2519      * @throws IOException on error
2520      */
2521     public List<Event> searchTasksOnly(String folderPath) throws IOException {
2522         return searchEvents(folderPath, and(isEqualTo("outlookmessageclass", "IPM.Task"),
2523                 or(isNull("datecompleted"), getPastDelayCondition("datecompleted"))));
2524     }
2525 
2526     /**
2527      * Search calendar events in provided folder.
2528      *
2529      * @param folderPath Exchange folder path
2530      * @param filter     search filter
2531      * @return list of calendar events
2532      * @throws IOException on error
2533      */
2534     public List<Event> searchEvents(String folderPath, Condition filter) throws IOException {
2535 
2536         Condition privateCondition = null;
2537         if (isSharedFolder(folderPath) && Settings.getBooleanProperty("davmail.excludePrivateEvents", true)) {
2538             LOGGER.debug("Shared or public calendar: exclude private events");
2539             privateCondition = isEqualTo("sensitivity", 0);
2540         }
2541 
2542         return searchEvents(folderPath, getItemProperties(),
2543                 and(filter, privateCondition));
2544     }
2545 
2546     /**
2547      * Search calendar events or messages in provided folder matching the search query.
2548      *
2549      * @param folderPath Exchange folder path
2550      * @param attributes requested attributes
2551      * @param condition  Exchange search query
2552      * @return list of calendar messages as Event objects
2553      * @throws IOException on error
2554      */
2555     public abstract List<Event> searchEvents(String folderPath, Set<String> attributes, Condition condition) throws IOException;
2556 
2557     /**
2558      * convert vcf extension to EML.
2559      *
2560      * @param itemName item name
2561      * @return EML item name
2562      */
2563     protected String convertItemNameToEML(String itemName) {
2564         if (itemName.endsWith(".vcf")) {
2565             return itemName.substring(0, itemName.length() - 3) + "EML";
2566         } else {
2567             return itemName;
2568         }
2569     }
2570 
2571     /**
2572      * Get item named eventName in folder
2573      *
2574      * @param folderPath Exchange folder path
2575      * @param itemName   event name
2576      * @return event object
2577      * @throws IOException on error
2578      */
2579     public abstract Item getItem(String folderPath, String itemName) throws IOException;
2580 
2581     /**
2582      * Contact picture
2583      */
2584     public static class ContactPhoto {
2585         /**
2586          * Contact picture content type (always image/jpeg on read)
2587          */
2588         public String contentType;
2589         /**
2590          * Base64 encoded picture content
2591          */
2592         public String content;
2593     }
2594 
2595     /**
2596      * Retrieve contact photo attached to contact
2597      *
2598      * @param contact address book contact
2599      * @return contact photo
2600      * @throws IOException on error
2601      */
2602     public abstract ContactPhoto getContactPhoto(Contact contact) throws IOException;
2603 
2604     /**
2605      * Retrieve contact photo from AD
2606      *
2607      * @param email address book contact
2608      * @return contact photo
2609      */
2610     public ContactPhoto getADPhoto(String email) {
2611         return null;
2612     }
2613 
2614     /**
2615      * Delete event named itemName in folder
2616      *
2617      * @param folderPath Exchange folder path
2618      * @param itemName   item name
2619      * @throws IOException on error
2620      */
2621     public abstract void deleteItem(String folderPath, String itemName) throws IOException;
2622 
2623     /**
2624      * Mark event processed named eventName in folder
2625      *
2626      * @param folderPath Exchange folder path
2627      * @param itemName   item name
2628      * @throws IOException on error
2629      */
2630     public abstract void processItem(String folderPath, String itemName) throws IOException;
2631 
2632 
2633     private static int dumpIndex;
2634 
2635     /**
2636      * Replace iCal4 (Snow Leopard) principal paths with mailto expression
2637      *
2638      * @param value attendee value or ics line
2639      * @return fixed value
2640      */
2641     protected String replaceIcal4Principal(String value) {
2642         if (value != null && value.contains("/principals/__uuids__/")) {
2643             return value.replaceAll("/principals/__uuids__/([^/]*)__AT__([^/]*)/", "mailto:$1@$2");
2644         } else {
2645             return value;
2646         }
2647     }
2648 
2649     /**
2650      * Event result object to hold HTTP status and event etag from an event creation/update.
2651      */
2652     public static class ItemResult {
2653         /**
2654          * HTTP status
2655          */
2656         public int status;
2657         /**
2658          * Event etag from response HTTP header
2659          */
2660         public String etag;
2661         /**
2662          * Created item name
2663          */
2664         public String itemName;
2665     }
2666 
2667     /**
2668      * Build and send the MIME message for the provided ICS event.
2669      *
2670      * @param icsBody event in iCalendar format
2671      * @return HTTP status
2672      * @throws IOException on error
2673      */
2674     public abstract int sendEvent(String icsBody) throws IOException;
2675 
2676     /**
2677      * Create or update item (event or contact) on the Exchange server
2678      *
2679      * @param folderPath Exchange folder path
2680      * @param itemName   event name
2681      * @param itemBody   event body in iCalendar format
2682      * @param etag       previous event etag to detect concurrent updates
2683      * @param noneMatch  if-none-match header value
2684      * @return HTTP response event result (status and etag)
2685      * @throws IOException on error
2686      */
2687     public ItemResult createOrUpdateItem(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException {
2688         if (itemBody.startsWith("BEGIN:VCALENDAR")) {
2689             return internalCreateOrUpdateEvent(folderPath, itemName, "urn:content-classes:appointment", itemBody, etag, noneMatch);
2690         } else if (itemBody.startsWith("BEGIN:VCARD")) {
2691             return createOrUpdateContact(folderPath, itemName, itemBody, etag, noneMatch);
2692         } else {
2693             throw new IOException(BundleMessage.format("EXCEPTION_INVALID_MESSAGE_CONTENT", itemBody));
2694         }
2695     }
2696 
2697     static final String[] VCARD_N_PROPERTIES = {"sn", "givenName", "middlename", "personaltitle", "namesuffix"};
2698     static final String[] VCARD_ADR_HOME_PROPERTIES = {"homepostofficebox", null, "homeStreet", "homeCity", "homeState", "homePostalCode", "homeCountry"};
2699     static final String[] VCARD_ADR_WORK_PROPERTIES = {"postofficebox", "roomnumber", "street", "l", "st", "postalcode", "co"};
2700     static final String[] VCARD_ADR_OTHER_PROPERTIES = {"otherpostofficebox", null, "otherstreet", "othercity", "otherstate", "otherpostalcode", "othercountry"};
2701     static final String[] VCARD_ORG_PROPERTIES = {"o", "department"};
2702 
2703     protected void convertContactProperties(Map<String, String> properties, String[] contactProperties, List<String> values) {
2704         for (int i = 0; i < values.size() && i < contactProperties.length; i++) {
2705             if (contactProperties[i] != null) {
2706                 properties.put(contactProperties[i], values.get(i));
2707             }
2708         }
2709     }
2710 
2711     protected ItemResult createOrUpdateContact(String folderPath, String itemName, String itemBody, String etag, String noneMatch) throws IOException {
2712         // parse VCARD body to build contact property map
2713         Map<String, String> properties = new HashMap<>();
2714 
2715         VObject vcard = new VObject(new ICSBufferedReader(new StringReader(itemBody)));
2716         if ("group".equalsIgnoreCase(vcard.getPropertyValue("KIND"))) {
2717             properties.put("outlookmessageclass", "IPM.DistList");
2718             properties.put("displayname", vcard.getPropertyValue("FN"));
2719         } else {
2720             properties.put("outlookmessageclass", "IPM.Contact");
2721 
2722             for (VProperty property : vcard.getProperties()) {
2723                 if ("FN".equals(property.getKey())) {
2724                     properties.put("cn", property.getValue());
2725                     properties.put("subject", property.getValue());
2726                     properties.put("fileas", property.getValue());
2727 
2728                 } else if ("N".equals(property.getKey())) {
2729                     convertContactProperties(properties, VCARD_N_PROPERTIES, property.getValues());
2730                 } else if ("NICKNAME".equals(property.getKey())) {
2731                     properties.put("nickname", property.getValue());
2732                 } else if ("TEL".equals(property.getKey())) {
2733                     if (property.hasParam("TYPE", "cell") || property.hasParam("X-GROUP", "cell")) {
2734                         properties.put("mobile", property.getValue());
2735                     } else if (property.hasParam("TYPE", "work") || property.hasParam("X-GROUP", "work")) {
2736                         properties.put("telephoneNumber", property.getValue());
2737                     } else if (property.hasParam("TYPE", "home") || property.hasParam("X-GROUP", "home")) {
2738                         properties.put("homePhone", property.getValue());
2739                     } else if (property.hasParam("TYPE", "fax")) {
2740                         if (property.hasParam("TYPE", "home")) {
2741                             properties.put("homefax", property.getValue());
2742                         } else {
2743                             properties.put("facsimiletelephonenumber", property.getValue());
2744                         }
2745                     } else if (property.hasParam("TYPE", "pager")) {
2746                         properties.put("pager", property.getValue());
2747                     } else if (property.hasParam("TYPE", "car")) {
2748                         properties.put("othermobile", property.getValue());
2749                     } else {
2750                         properties.put("otherTelephone", property.getValue());
2751                     }
2752                 } else if ("ADR".equals(property.getKey())) {
2753                     // address
2754                     if (property.hasParam("TYPE", "home")) {
2755                         convertContactProperties(properties, VCARD_ADR_HOME_PROPERTIES, property.getValues());
2756                     } else if (property.hasParam("TYPE", "work")) {
2757                         convertContactProperties(properties, VCARD_ADR_WORK_PROPERTIES, property.getValues());
2758                         // any other type goes to other address
2759                     } else {
2760                         convertContactProperties(properties, VCARD_ADR_OTHER_PROPERTIES, property.getValues());
2761                     }
2762                 } else if ("EMAIL".equals(property.getKey())) {
2763                     if (property.hasParam("TYPE", "home")) {
2764                         properties.put("email2", property.getValue());
2765                         properties.put("smtpemail2", property.getValue());
2766                     } else if (property.hasParam("TYPE", "other")) {
2767                         properties.put("email3", property.getValue());
2768                         properties.put("smtpemail3", property.getValue());
2769                     } else {
2770                         properties.put("email1", property.getValue());
2771                         properties.put("smtpemail1", property.getValue());
2772                     }
2773                 } else if ("ORG".equals(property.getKey())) {
2774                     convertContactProperties(properties, VCARD_ORG_PROPERTIES, property.getValues());
2775                 } else if ("URL".equals(property.getKey())) {
2776                     if (property.hasParam("TYPE", "work")) {
2777                         properties.put("businesshomepage", property.getValue());
2778                     } else if (property.hasParam("TYPE", "home")) {
2779                         properties.put("personalHomePage", property.getValue());
2780                     } else {
2781                         // default: set personal home page
2782                         properties.put("personalHomePage", property.getValue());
2783                     }
2784                 } else if ("TITLE".equals(property.getKey())) {
2785                     properties.put("title", property.getValue());
2786                 } else if ("NOTE".equals(property.getKey())) {
2787                     properties.put("description", property.getValue());
2788                 } else if ("CUSTOM1".equals(property.getKey())) {
2789                     properties.put("extensionattribute1", property.getValue());
2790                 } else if ("CUSTOM2".equals(property.getKey())) {
2791                     properties.put("extensionattribute2", property.getValue());
2792                 } else if ("CUSTOM3".equals(property.getKey())) {
2793                     properties.put("extensionattribute3", property.getValue());
2794                 } else if ("CUSTOM4".equals(property.getKey())) {
2795                     properties.put("extensionattribute4", property.getValue());
2796                 } else if ("ROLE".equals(property.getKey())) {
2797                     properties.put("profession", property.getValue());
2798                 } else if ("X-AIM".equals(property.getKey())) {
2799                     properties.put("im", property.getValue());
2800                 } else if ("BDAY".equals(property.getKey())) {
2801                     properties.put("bday", convertBDayToZulu(property.getValue()));
2802                 } else if ("ANNIVERSARY".equals(property.getKey()) || "X-ANNIVERSARY".equals(property.getKey())) {
2803                     properties.put("anniversary", convertBDayToZulu(property.getValue()));
2804                 } else if ("CATEGORIES".equals(property.getKey())) {
2805                     properties.put("keywords", property.getValue());
2806                 } else if ("CLASS".equals(property.getKey())) {
2807                     if ("PUBLIC".equals(property.getValue())) {
2808                         properties.put("sensitivity", "0");
2809                         properties.put("private", "false");
2810                     } else {
2811                         properties.put("sensitivity", "2");
2812                         properties.put("private", "true");
2813                     }
2814                 } else if ("SEX".equals(property.getKey())) {
2815                     String propertyValue = property.getValue();
2816                     if ("1".equals(propertyValue)) {
2817                         properties.put("gender", "2");
2818                     } else if ("2".equals(propertyValue)) {
2819                         properties.put("gender", "1");
2820                     }
2821                 } else if ("FBURL".equals(property.getKey())) {
2822                     properties.put("fburl", property.getValue());
2823                 } else if ("X-ASSISTANT".equals(property.getKey())) {
2824                     properties.put("secretarycn", property.getValue());
2825                 } else if ("X-MANAGER".equals(property.getKey())) {
2826                     properties.put("manager", property.getValue());
2827                 } else if ("X-SPOUSE".equals(property.getKey())) {
2828                     properties.put("spousecn", property.getValue());
2829                 } else if ("PHOTO".equals(property.getKey())) {
2830                     String value = property.getValue();
2831                     if ("data:image/jpeg".equals(value) && property.values.size() > 1) {
2832                         value = property.getValues().get(1);
2833                         if (value.startsWith("base64,")) {
2834                             value = value.substring(7);
2835                         }
2836                     }
2837                     properties.put("photo", value);
2838                     properties.put("haspicture", "true");
2839                 } else if ("KEY1".equals(property.getKey())) {
2840                     properties.put("msexchangecertificate", property.getValue());
2841                 } else if ("KEY2".equals(property.getKey())) {
2842                     properties.put("usersmimecertificate", property.getValue());
2843                 }
2844             }
2845             LOGGER.debug("Create or update contact " + itemName + ": " + properties);
2846             // reset missing properties to null
2847             for (String key : CONTACT_ATTRIBUTES) {
2848                 if (!"imapUid".equals(key) && !"etag".equals(key) && !"urlcompname".equals(key)
2849                         && !"lastmodified".equals(key) && !"sensitivity".equals(key)
2850                         && !"haspicture".equals(key)
2851                         && !properties.containsKey(key)) {
2852                     properties.put(key, null);
2853                 }
2854             }
2855         }
2856 
2857         Contact contact = buildContact(folderPath, itemName, properties, etag, noneMatch);
2858         for (VProperty property : vcard.getProperties()) {
2859             if ("MEMBER".equals(property.getKey())) {
2860                 String member = property.getValue();
2861                 if (member.startsWith("urn:uuid:")) {
2862                     Item item = getItem(folderPath, member.substring(9) + ".EML");
2863                     if (item != null) {
2864                         if (item.get("smtpemail1") != null) {
2865                             member = "mailto:" + item.get("smtpemail1");
2866                         } else if (item.get("smtpemail2") != null) {
2867                             member = "mailto:" + item.get("smtpemail2");
2868                         } else if (item.get("smtpemail3") != null) {
2869                             member = "mailto:" + item.get("smtpemail3");
2870                         }
2871                     }
2872                 }
2873                 contact.addMember(member);
2874             }
2875         }
2876         return contact.createOrUpdate();
2877     }
2878 
2879     protected String convertZuluDateToBday(String value) {
2880         String result = null;
2881         if (value != null && !value.isEmpty()) {
2882             try {
2883                 SimpleDateFormat parser = ExchangeSession.getZuluDateFormat();
2884                 Calendar cal = Calendar.getInstance();
2885                 cal.setTime(parser.parse(value));
2886                 cal.add(Calendar.HOUR_OF_DAY, 12);
2887                 result = ExchangeSession.getVcardBdayFormat().format(cal.getTime());
2888             } catch (ParseException e) {
2889                 LOGGER.warn("Invalid date: " + value);
2890             }
2891         }
2892         return result;
2893     }
2894 
2895     protected String convertBDayToZulu(String value) {
2896         String result = null;
2897         if (value != null && !value.isEmpty()) {
2898             try {
2899                 SimpleDateFormat parser;
2900                 if (value.length() == 8) {
2901                     parser = new SimpleDateFormat("yyyyMMdd", Locale.ENGLISH);
2902                     parser.setTimeZone(GMT_TIMEZONE);
2903                 } else if (value.length() == 10) {
2904                     parser = ExchangeSession.getVcardBdayFormat();
2905                 } else if (value.length() == 15) {
2906                     parser = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ENGLISH);
2907                     parser.setTimeZone(GMT_TIMEZONE);
2908                 } else {
2909                     parser = ExchangeSession.getExchangeZuluDateFormat();
2910                 }
2911                 result = ExchangeSession.getExchangeZuluDateFormatMillisecond().format(parser.parse(value));
2912             } catch (ParseException e) {
2913                 LOGGER.warn("Invalid date: " + value);
2914             }
2915         }
2916 
2917         return result;
2918     }
2919 
2920 
2921     protected abstract Contact buildContact(String folderPath, String itemName, Map<String, String> properties, String etag, String noneMatch) throws IOException;
2922 
2923     protected abstract ItemResult internalCreateOrUpdateEvent(String folderPath, String itemName, String contentClass, String icsBody, String etag, String noneMatch) throws IOException;
2924 
2925     /**
2926      * Get current Exchange alias name from login name
2927      *
2928      * @return user name
2929      */
2930     public String getAliasFromLogin() {
2931         // login is email, not alias
2932         if (this.userName.indexOf('@') >= 0) {
2933             return null;
2934         }
2935         String result = this.userName;
2936         // remove domain name
2937         int index = Math.max(result.indexOf('\\'), result.indexOf('/'));
2938         if (index >= 0) {
2939             result = result.substring(index + 1);
2940         }
2941         return result;
2942     }
2943 
2944     /**
2945      * Test if folderPath is inside user mailbox.
2946      *
2947      * @param folderPath absolute folder path
2948      * @return true if folderPath is a public or shared folder
2949      */
2950     public abstract boolean isSharedFolder(String folderPath);
2951 
2952     /**
2953      * Test if folderPath is main calendar.
2954      *
2955      * @param folderPath absolute folder path
2956      * @return true if folderPath is a public or shared folder
2957      */
2958     public abstract boolean isMainCalendar(String folderPath) throws IOException;
2959 
2960     protected static final String MAILBOX_BASE = "/cn=";
2961 
2962     /**
2963      * Get current user email
2964      *
2965      * @return user email
2966      */
2967     public String getEmail() {
2968         return email;
2969     }
2970 
2971     /**
2972      * Get email from current calendar
2973      * @param folderPath calendar folder path
2974      * @return calendar mailbox
2975      */
2976     protected abstract String getCalendarEmail(String folderPath) throws IOException;
2977 
2978     /**
2979      * Get current user alias
2980      *
2981      * @return user email
2982      */
2983     public String getAlias() {
2984         return alias;
2985     }
2986 
2987     /**
2988      * Search global address list
2989      *
2990      * @param condition           search filter
2991      * @param returningAttributes returning attributes
2992      * @param sizeLimit           size limit
2993      * @return matching contacts from gal
2994      * @throws IOException on error
2995      */
2996     public abstract Map<String, Contact> galFind(Condition condition, Set<String> returningAttributes, int sizeLimit) throws IOException;
2997 
2998     /**
2999      * Full Contact attribute list
3000      */
3001     public static final Set<String> CONTACT_ATTRIBUTES = new HashSet<>();
3002 
3003     static {
3004         CONTACT_ATTRIBUTES.add("imapUid");
3005         CONTACT_ATTRIBUTES.add("etag");
3006         CONTACT_ATTRIBUTES.add("urlcompname");
3007 
3008         CONTACT_ATTRIBUTES.add("extensionattribute1");
3009         CONTACT_ATTRIBUTES.add("extensionattribute2");
3010         CONTACT_ATTRIBUTES.add("extensionattribute3");
3011         CONTACT_ATTRIBUTES.add("extensionattribute4");
3012         CONTACT_ATTRIBUTES.add("bday");
3013         CONTACT_ATTRIBUTES.add("anniversary");
3014         CONTACT_ATTRIBUTES.add("businesshomepage");
3015         CONTACT_ATTRIBUTES.add("personalHomePage");
3016         CONTACT_ATTRIBUTES.add("cn");
3017         CONTACT_ATTRIBUTES.add("co");
3018         CONTACT_ATTRIBUTES.add("department");
3019         CONTACT_ATTRIBUTES.add("smtpemail1");
3020         CONTACT_ATTRIBUTES.add("smtpemail2");
3021         CONTACT_ATTRIBUTES.add("smtpemail3");
3022         CONTACT_ATTRIBUTES.add("facsimiletelephonenumber");
3023         CONTACT_ATTRIBUTES.add("givenName");
3024         CONTACT_ATTRIBUTES.add("homeCity");
3025         CONTACT_ATTRIBUTES.add("homeCountry");
3026         CONTACT_ATTRIBUTES.add("homePhone");
3027         CONTACT_ATTRIBUTES.add("homePostalCode");
3028         CONTACT_ATTRIBUTES.add("homeState");
3029         CONTACT_ATTRIBUTES.add("homeStreet");
3030         CONTACT_ATTRIBUTES.add("homepostofficebox");
3031         CONTACT_ATTRIBUTES.add("l");
3032         CONTACT_ATTRIBUTES.add("manager");
3033         CONTACT_ATTRIBUTES.add("mobile");
3034         CONTACT_ATTRIBUTES.add("namesuffix");
3035         CONTACT_ATTRIBUTES.add("nickname");
3036         CONTACT_ATTRIBUTES.add("o");
3037         CONTACT_ATTRIBUTES.add("pager");
3038         CONTACT_ATTRIBUTES.add("personaltitle");
3039         CONTACT_ATTRIBUTES.add("postalcode");
3040         CONTACT_ATTRIBUTES.add("postofficebox");
3041         CONTACT_ATTRIBUTES.add("profession");
3042         CONTACT_ATTRIBUTES.add("roomnumber");
3043         CONTACT_ATTRIBUTES.add("secretarycn");
3044         CONTACT_ATTRIBUTES.add("sn");
3045         CONTACT_ATTRIBUTES.add("spousecn");
3046         CONTACT_ATTRIBUTES.add("st");
3047         CONTACT_ATTRIBUTES.add("street");
3048         CONTACT_ATTRIBUTES.add("telephoneNumber");
3049         CONTACT_ATTRIBUTES.add("title");
3050         CONTACT_ATTRIBUTES.add("description");
3051         CONTACT_ATTRIBUTES.add("im");
3052         CONTACT_ATTRIBUTES.add("middlename");
3053         CONTACT_ATTRIBUTES.add("lastmodified");
3054         CONTACT_ATTRIBUTES.add("otherstreet");
3055         CONTACT_ATTRIBUTES.add("otherstate");
3056         CONTACT_ATTRIBUTES.add("otherpostofficebox");
3057         CONTACT_ATTRIBUTES.add("otherpostalcode");
3058         CONTACT_ATTRIBUTES.add("othercountry");
3059         CONTACT_ATTRIBUTES.add("othercity");
3060         CONTACT_ATTRIBUTES.add("haspicture");
3061         CONTACT_ATTRIBUTES.add("keywords");
3062         CONTACT_ATTRIBUTES.add("othermobile");
3063         CONTACT_ATTRIBUTES.add("otherTelephone");
3064         CONTACT_ATTRIBUTES.add("gender");
3065         CONTACT_ATTRIBUTES.add("private");
3066         CONTACT_ATTRIBUTES.add("sensitivity");
3067         CONTACT_ATTRIBUTES.add("fburl");
3068         CONTACT_ATTRIBUTES.add("msexchangecertificate");
3069         CONTACT_ATTRIBUTES.add("usersmimecertificate");
3070     }
3071 
3072     public static final Set<String> ORG_CONTACT_ATTRIBUTES = new HashSet<>();
3073     static {
3074         // org contact attributes
3075         ORG_CONTACT_ATTRIBUTES.add("birthday");
3076         ORG_CONTACT_ATTRIBUTES.add("fileAs");
3077         ORG_CONTACT_ATTRIBUTES.add("displayName");
3078         ORG_CONTACT_ATTRIBUTES.add("initials");
3079         ORG_CONTACT_ATTRIBUTES.add("middleName");
3080         ORG_CONTACT_ATTRIBUTES.add("surname");
3081         ORG_CONTACT_ATTRIBUTES.add("jobTitle");
3082         ORG_CONTACT_ATTRIBUTES.add("companyName");
3083         ORG_CONTACT_ATTRIBUTES.add("officeLocation");
3084         ORG_CONTACT_ATTRIBUTES.add("personalNotes");
3085     }
3086 
3087     protected static final Set<String> DISTRIBUTION_LIST_ATTRIBUTES = new HashSet<>();
3088 
3089     static {
3090         DISTRIBUTION_LIST_ATTRIBUTES.add("imapUid");
3091         DISTRIBUTION_LIST_ATTRIBUTES.add("etag");
3092         DISTRIBUTION_LIST_ATTRIBUTES.add("urlcompname");
3093 
3094         DISTRIBUTION_LIST_ATTRIBUTES.add("cn");
3095         DISTRIBUTION_LIST_ATTRIBUTES.add("members");
3096     }
3097 
3098     /**
3099      * Get freebusy data string from Exchange.
3100      *
3101      * @param attendee attendee email address
3102      * @param start    start date in Exchange zulu format
3103      * @param end      end date in Exchange zulu format
3104      * @param interval freebusy interval in minutes
3105      * @return freebusy data or null
3106      * @throws IOException on error
3107      */
3108     protected abstract String getFreeBusyData(String attendee, String start, String end, int interval) throws IOException;
3109 
3110     /**
3111      * Get freebusy info for attendee between start and end date.
3112      *
3113      * @param attendee       attendee email
3114      * @param startDateValue start date
3115      * @param endDateValue   end date
3116      * @return FreeBusy info
3117      * @throws IOException on error
3118      */
3119     public FreeBusy getFreebusy(String attendee, String startDateValue, String endDateValue) throws IOException {
3120         // replace ical encoded attendee name
3121         attendee = replaceIcal4Principal(attendee);
3122 
3123         // then check that email address is valid to avoid InvalidSmtpAddress error
3124         if (attendee == null || attendee.indexOf('@') < 0 || attendee.charAt(attendee.length() - 1) == '@') {
3125             return null;
3126         }
3127 
3128         if (attendee.startsWith("mailto:") || attendee.startsWith("MAILTO:")) {
3129             attendee = attendee.substring("mailto:".length());
3130         }
3131 
3132         SimpleDateFormat exchangeZuluDateFormat = getExchangeZuluDateFormat();
3133         SimpleDateFormat icalDateFormat = getZuluDateFormat();
3134 
3135         Date startDate;
3136         Date endDate;
3137         try {
3138             if (startDateValue.length() == 8) {
3139                 startDate = parseDate(startDateValue);
3140             } else {
3141                 startDate = icalDateFormat.parse(startDateValue);
3142             }
3143             if (endDateValue.length() == 8) {
3144                 endDate = parseDate(endDateValue);
3145             } else {
3146                 endDate = icalDateFormat.parse(endDateValue);
3147             }
3148         } catch (ParseException e) {
3149             throw new DavMailException("EXCEPTION_INVALID_DATES", e.getMessage());
3150         }
3151 
3152         FreeBusy freeBusy = null;
3153         String fbdata = getFreeBusyData(attendee, exchangeZuluDateFormat.format(startDate), exchangeZuluDateFormat.format(endDate), FREE_BUSY_INTERVAL);
3154         if (fbdata != null) {
3155             freeBusy = new FreeBusy(icalDateFormat, startDate, fbdata);
3156         }
3157 
3158         if (freeBusy != null && freeBusy.knownAttendee) {
3159             return freeBusy;
3160         } else {
3161             return null;
3162         }
3163     }
3164 
3165     /**
3166      * Exchange to iCalendar Free/Busy parser.
3167      * Free time returns 0, Tentative returns 1, Busy returns 2, and Out of Office (OOF) returns 3
3168      */
3169     public static final class FreeBusy {
3170         final SimpleDateFormat icalParser;
3171         boolean knownAttendee = true;
3172         static final HashMap<Character, String> FBTYPES = new HashMap<>();
3173 
3174         static {
3175             FBTYPES.put('1', "BUSY-TENTATIVE");
3176             FBTYPES.put('2', "BUSY");
3177             FBTYPES.put('3', "BUSY-UNAVAILABLE");
3178         }
3179 
3180         final HashMap<String, StringBuilder> busyMap = new HashMap<>();
3181 
3182         StringBuilder getBusyBuffer(char type) {
3183             String fbType = FBTYPES.get(type);
3184             return busyMap.computeIfAbsent(fbType, k -> new StringBuilder());
3185         }
3186 
3187         void startBusy(char type, Calendar currentCal) {
3188             if (type == '4') {
3189                 knownAttendee = false;
3190             } else if (type != '0') {
3191                 StringBuilder busyBuffer = getBusyBuffer(type);
3192                 if (busyBuffer.length() > 0) {
3193                     busyBuffer.append(',');
3194                 }
3195                 busyBuffer.append(icalParser.format(currentCal.getTime()));
3196             }
3197         }
3198 
3199         void endBusy(char type, Calendar currentCal) {
3200             if (type != '0' && type != '4') {
3201                 getBusyBuffer(type).append('/').append(icalParser.format(currentCal.getTime()));
3202             }
3203         }
3204 
3205         FreeBusy(SimpleDateFormat icalParser, Date startDate, String fbdata) {
3206             this.icalParser = icalParser;
3207             if (!fbdata.isEmpty()) {
3208                 Calendar currentCal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
3209                 currentCal.setTime(startDate);
3210 
3211                 startBusy(fbdata.charAt(0), currentCal);
3212                 for (int i = 1; i < fbdata.length() && knownAttendee; i++) {
3213                     currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL);
3214                     char previousState = fbdata.charAt(i - 1);
3215                     char currentState = fbdata.charAt(i);
3216                     if (previousState != currentState) {
3217                         endBusy(previousState, currentCal);
3218                         startBusy(currentState, currentCal);
3219                     }
3220                 }
3221                 currentCal.add(Calendar.MINUTE, FREE_BUSY_INTERVAL);
3222                 endBusy(fbdata.charAt(fbdata.length() - 1), currentCal);
3223             }
3224         }
3225 
3226         /**
3227          * Append freebusy information to provided buffer.
3228          *
3229          * @param buffer String buffer
3230          */
3231         public void appendTo(StringBuilder buffer) {
3232             for (Map.Entry<String, StringBuilder> entry : busyMap.entrySet()) {
3233                 buffer.append("FREEBUSY;FBTYPE=").append(entry.getKey())
3234                         .append(':').append(entry.getValue()).append((char) 13).append((char) 10);
3235             }
3236         }
3237     }
3238 
3239     protected VObject vTimezone;
3240 
3241     /**
3242      * Load and return current user OWA timezone.
3243      *
3244      * @return current timezone
3245      */
3246     public VObject getVTimezone() {
3247         if (vTimezone == null) {
3248             // need to load Timezone info from OWA
3249             loadVtimezone();
3250         }
3251         return vTimezone;
3252     }
3253 
3254     public void clearVTimezone() {
3255         vTimezone = null;
3256     }
3257 
3258     protected abstract void loadVtimezone();
3259 
3260     public static final Map<String, String> vTodoToTaskStatusMap = new HashMap<>();
3261     public static final Map<String, String> taskTovTodoStatusMap = new HashMap<>();
3262     static {
3263         //taskTovTodoStatusMap.put("NotStarted", null);
3264         taskTovTodoStatusMap.put("InProgress", "IN-PROCESS");
3265         taskTovTodoStatusMap.put("Completed", "COMPLETED");
3266         taskTovTodoStatusMap.put("WaitingOnOthers", "NEEDS-ACTION");
3267         taskTovTodoStatusMap.put("Deferred", "CANCELLED");
3268 
3269         //vTodoToTaskStatusMap.put(null, "NotStarted");
3270         vTodoToTaskStatusMap.put("IN-PROCESS", "InProgress");
3271         vTodoToTaskStatusMap.put("COMPLETED", "Completed");
3272         vTodoToTaskStatusMap.put("NEEDS-ACTION", "WaitingOnOthers");
3273         vTodoToTaskStatusMap.put("CANCELLED", "Deferred");
3274 
3275     }
3276 
3277     protected static final Map<String, String> importanceToPriorityMap = new HashMap<>();
3278 
3279     static {
3280         importanceToPriorityMap.put("High", "1");
3281         importanceToPriorityMap.put("Normal", "5");
3282         importanceToPriorityMap.put("Low", "9");
3283     }
3284 
3285     protected static final Map<String, String> priorityToImportanceMap = new HashMap<>();
3286 
3287     static {
3288         // 0 means undefined, map it to normal
3289         priorityToImportanceMap.put("0", "Normal");
3290 
3291         priorityToImportanceMap.put("1", "High");
3292         priorityToImportanceMap.put("2", "High");
3293         priorityToImportanceMap.put("3", "High");
3294         priorityToImportanceMap.put("4", "Normal");
3295         priorityToImportanceMap.put("5", "Normal");
3296         priorityToImportanceMap.put("6", "Normal");
3297         priorityToImportanceMap.put("7", "Low");
3298         priorityToImportanceMap.put("8", "Low");
3299         priorityToImportanceMap.put("9", "Low");
3300     }
3301 
3302     protected String convertPriorityFromExchange(String exchangeImportanceValue) {
3303         String value = null;
3304         if (exchangeImportanceValue != null) {
3305             value = importanceToPriorityMap.get(exchangeImportanceValue);
3306         }
3307         return value;
3308     }
3309 
3310     protected String convertPriorityToExchange(String vTodoPriorityValue) {
3311         String value = null;
3312         if (vTodoPriorityValue != null) {
3313             value = priorityToImportanceMap.get(vTodoPriorityValue);
3314         }
3315         return value;
3316     }
3317 
3318     /**
3319      * Possible values are: normal, personal, private, and confidential.
3320      * @param sensitivity Exchange sensivity
3321      * @return event class
3322      */
3323     protected String convertClassFromExchange(String sensitivity) {
3324         String eventClass;
3325         if ("private".equals(sensitivity)) {
3326             eventClass = "PRIVATE";
3327         } else if ("confidential".equals(sensitivity)) {
3328             eventClass = "CONFIDENTIAL";
3329         } else if ("personal".equals(sensitivity)) {
3330             eventClass = "PRIVATE";
3331         } else {
3332             // normal
3333             eventClass = "PUBLIC";
3334         }
3335         return eventClass;
3336     }
3337 
3338 }